/**
 * Source-backed design-token extraction.
 *
 * Pipeline shape: parse token sources → classify/dedupe → detect hardcoded
 * values → serialize code-token payload.
 */

import { normalizeColorValue } from "./categorize.js";
import { classifyTokenTiers, deduplicateByIdentity } from "./classify.js";
import { detectHardcodedValues, type TokenCandidate } from "./detect-hardcoded.js";
import {
  deduplicateBySelector,
  parseCSSTokens,
  parseDTCGTokens,
  parseSCSSVariables,
  parseTailwindV4Theme,
} from "./parse.js";
import { getScssResolutionKey, resolveScssTokenValues } from "./scss-resolver.js";
import { buildDTCGPayload } from "./serialize.js";
import type {
  DuplicateFinding,
  HardcodedFinding,
  ParsedToken,
  RepoTreeEntry,
  TokenExtractionProgress,
  TokenExtractionResult,
  TokenProvenanceConfig,
} from "./types.js";

export { classifyTokenTiers, computeIdentityKey, deduplicateByIdentity } from "./classify.js";
export { inferCategory, isColorValue, normalizeColorValue } from "./categorize.js";
export { buildDTCGPayload } from "./serialize.js";
export type {
  DuplicateFinding,
  HardcodedFinding,
  ParsedToken,
  RepoTreeEntry,
  TokenExtractionProgress,
  TokenExtractionResult,
  TokenProvenanceConfig,
  TokenTier,
} from "./types.js";

const MAX_TOKEN_FILES = 100;
const MAX_ONBOARDING_TOKEN_FILES = 40;
const MAX_FILE_BYTES = 500 * 1024;
const MAX_STYLE_FILES = 300;
const CONCURRENCY = 5;
type TokenScanProfile = "full" | "onboarding";

export interface TokenFileStore {
  getText(path: string): Promise<string | null>;
}

export interface ExtractSourceTokensArgs {
  tree: RepoTreeEntry[];
  fileStore: TokenFileStore;
  provenance?: TokenProvenanceConfig;
  scanProfile?: TokenScanProfile;
  /**
   * Tokens already parsed by a prior scan (typically an onboarding run we
   * are extending into a full run). These are merged into the local token
   * lookup so duplicate detection and hardcoded value detection see the
   * union, without re-fetching the source files.
   */
  priorParsedTokens?: ParsedToken[];
  /**
   * Token file paths covered by the parent scan. Files already in this
   * set are skipped during token I/O so the full follow-up only fetches
   * the marginal set (e.g. files 41..100 when onboarding covered 0..40).
   */
  priorTokenFilePaths?: ReadonlySet<string>;
  onProgress?: (progress: TokenExtractionProgress) => void;
  /**
   * Streaming callback. Emits batches of findings as they are produced:
   * duplicate-token findings once just after the token phase, and
   * hardcoded-value findings per CONCURRENCY-style-file batch. Callers
   * use this to update dashboards in near-real-time instead of waiting
   * for the whole scan to finish.
   */
  onIncrementalFindings?: (event: {
    hardcoded?: HardcodedFinding[];
    duplicate?: DuplicateFinding[];
  }) => void | Promise<void>;
}

function isExcludedDir(lower: string) {
  const excluded = new Set([
    "node_modules",
    ".vercel",
    "dist",
    "build",
    "coverage",
    "__tests__",
    "benchmarks",
    "examples",
    "e2e",
    "scripts",
    ".next",
    ".turbo",
  ]);
  return lower
    .split("/")
    .filter(Boolean)
    .some((segment) => excluded.has(segment));
}

const TOKEN_SEGMENTS = new Set([
  "tokens",
  "design-tokens",
  "theme",
  "themes",
  "styles",
  "foundations",
]);
const STYLE_LIBRARY_SEGMENTS = new Set([
  "components",
  "component",
  "primitives",
  "primitive",
  "ui",
]);
const PACKAGE_SEGMENTS = new Set(["libs", "lib", "packages", "package"]);
const APP_SEGMENTS = new Set(["app", "apps", "pages", "routes", "screens"]);

function pathSegments(path: string) {
  return path.toLowerCase().split("/").filter(Boolean);
}

export function rankStylePathForExtraction(path: string) {
  const lower = path.toLowerCase();
  const segments = pathSegments(path);
  let score = 50;

  if (lower.endsWith(".tokens.json") || /(?:^|\/)(tokens|theme)\.json$/.test(lower)) {
    score -= 28;
  }
  if (segments.some((segment) => TOKEN_SEGMENTS.has(segment))) score -= 24;
  if (segments.some((segment) => STYLE_LIBRARY_SEGMENTS.has(segment))) {
    score -= 12;
  }
  if (segments.some((segment) => PACKAGE_SEGMENTS.has(segment))) score -= 8;
  if (/(^|\/)src\/(styles|tokens|theme)\//.test(lower)) score -= 8;
  if (/(^|\/)components\/ui\//.test(lower)) score -= 6;
  if (segments.some((segment) => APP_SEGMENTS.has(segment))) score += 12;

  return score;
}

function compareByStylePriority(a: RepoTreeEntry, b: RepoTreeEntry) {
  const rankDelta = rankStylePathForExtraction(a.path) - rankStylePathForExtraction(b.path);
  if (rankDelta !== 0) return rankDelta;
  return a.path.localeCompare(b.path);
}

function isStyleFile(path: string) {
  const lower = path.toLowerCase();
  if (isExcludedDir(lower)) return false;
  return (
    lower.endsWith(".css") ||
    lower.endsWith(".scss") ||
    lower.endsWith(".module.css") ||
    lower.endsWith(".module.scss")
  );
}

function isTokenFile(path: string) {
  const lower = path.toLowerCase();
  if (isExcludedDir(lower)) return false;
  if (
    lower.endsWith(".contract.json") ||
    lower.endsWith(".schema.json") ||
    lower.endsWith("/fragments.json") ||
    lower.endsWith("/components.json")
  ) {
    return false;
  }
  if (lower.endsWith(".tokens.json")) return true;
  if (/(?:^|\/)tokens\.json$/.test(lower)) return true;
  if (/\/tokens\/[^/]+\.json$/.test(lower)) return true;
  if (/\/design-tokens\/[^/]+\.json$/.test(lower)) return true;
  if (/(?:^|\/)theme\.json$/.test(lower)) return true;
  if (/\/(tokens|design-tokens|styles|foundations)\/theme\/[^/]+\.json$/.test(lower)) {
    return true;
  }
  if (lower.endsWith(".module.scss") || lower.endsWith(".module.css")) return false;
  return lower.endsWith(".css") || lower.endsWith(".scss");
}

function extractTokensFromText(text: string, filePath: string) {
  const lower = filePath.toLowerCase();
  const tokens: ParsedToken[] = [];

  if (lower.endsWith(".json")) {
    tokens.push(...parseDTCGTokens(text, filePath));
  } else if (lower.endsWith(".css")) {
    const cssTokens = parseCSSTokens(text, filePath);
    const deduped = deduplicateBySelector(cssTokens);
    tokens.push(...deduped);
    if (/@theme\b/.test(text)) {
      const themeTokens = parseTailwindV4Theme(text, filePath);
      const existingNames = new Set(tokens.map((t) => t.name));
      for (const tt of themeTokens) {
        if (!existingNames.has(tt.name)) {
          tokens.push(tt);
        }
      }
    }
  } else if (lower.endsWith(".scss")) {
    tokens.push(...parseSCSSVariables(text, filePath));
    const cssTokens = parseCSSTokens(text, filePath);
    tokens.push(...deduplicateBySelector(cssTokens));
  }

  return tokens;
}

export async function extractSourceTokens(
  args: ExtractSourceTokensArgs
): Promise<TokenExtractionResult> {
  const scanProfile = args.scanProfile ?? "full";
  const priorPaths = args.priorTokenFilePaths ?? new Set<string>();
  const cap = scanProfile === "onboarding" ? MAX_ONBOARDING_TOKEN_FILES : MAX_TOKEN_FILES;
  // Budget the cap against inherited files so the union (prior + new)
  // doesn't blow past the per-profile limit.
  const remainingCap = Math.max(0, cap - priorPaths.size);
  const tokenFiles = args.tree
    .filter(
      (entry) =>
        entry.type === "blob" &&
        isTokenFile(entry.path) &&
        !priorPaths.has(entry.path) &&
        (entry.size ?? 0) <= MAX_FILE_BYTES
    )
    .sort(compareByStylePriority)
    .slice(0, remainingCap);

  const allTokens: ParsedToken[] = args.priorParsedTokens ? [...args.priorParsedTokens] : [];
  const inheritedTokenCount = allTokens.length;
  const tokenFileTexts = new Map<string, string>();
  let filesProcessed = 0;

  args.onProgress?.({
    phase: "tokens",
    filesProcessed: 0,
    totalFiles: tokenFiles.length,
    tokensFound: inheritedTokenCount,
    hardcodedFound: 0,
  });

  for (let i = 0; i < tokenFiles.length; i += CONCURRENCY) {
    const batch = tokenFiles.slice(i, i + CONCURRENCY);
    const results = await Promise.allSettled(
      batch.map(async (file) => {
        const text = await args.fileStore.getText(file.path);
        if (text !== null) tokenFileTexts.set(file.path, text);
        return text === null ? [] : extractTokensFromText(text, file.path);
      })
    );

    for (const result of results) {
      if (result.status === "fulfilled") {
        allTokens.push(...result.value);
      }
    }

    filesProcessed += batch.length;
    args.onProgress?.({
      phase: "tokens",
      filesProcessed,
      totalFiles: tokenFiles.length,
      tokensFound: allTokens.length,
      hardcodedFound: 0,
    });
  }

  const resolvedScssValues = await resolveScssTokenValues({
    tokens: allTokens,
    fileTexts: tokenFileTexts,
  });
  if (resolvedScssValues.size > 0) {
    for (const token of allTokens) {
      const resolved = resolvedScssValues.get(getScssResolutionKey(token.file, token.name));
      if (resolved !== undefined) {
        token.value = resolved;
      }
    }
  }

  const tokenLookup = new Map<string, TokenCandidate[]>();
  for (const token of allTokens) {
    const normalized = normalizeColorValue(token.value);
    const existing = tokenLookup.get(normalized) ?? [];
    existing.push({ name: token.name, category: token.category });
    tokenLookup.set(normalized, existing);
  }

  const duplicateFindings: DuplicateFinding[] = [];
  const valueGroups = new Map<string, ParsedToken[]>();
  for (const token of allTokens) {
    if (token.value.startsWith("var(") || token.value.startsWith("$")) continue;
    const normalized = normalizeColorValue(token.value);
    const group = valueGroups.get(normalized) ?? [];
    group.push(token);
    valueGroups.set(normalized, group);
  }
  for (const [normalized, group] of valueGroups) {
    if (group.length < 2) continue;
    // Semantic aliases in the same file are intentional (e.g. --card
    // and --popover both set to the same base color in :root).
    const files = new Set(group.map((t) => t.file));
    if (files.size === 1) continue;
    // Deduplicate by token name — the same token defined across
    // theme files (light.css, dark.css) is not a true duplicate.
    const uniqueNames = [...new Set(group.map((t) => t.name))];
    if (uniqueNames.length < 2) continue;
    duplicateFindings.push({
      value: normalized,
      tokenNames: uniqueNames,
      files: [...files],
      category: group[0].category,
      fingerprint: `duplicate::${normalized}`,
    });
  }

  if (duplicateFindings.length && args.onIncrementalFindings) {
    await args.onIncrementalFindings({ duplicate: duplicateFindings });
  }

  const allStyleFiles = args.tree
    .filter(
      (entry) =>
        scanProfile === "full" &&
        entry.type === "blob" &&
        isStyleFile(entry.path) &&
        (entry.size ?? 0) <= MAX_FILE_BYTES
    )
    .sort(compareByStylePriority)
    .slice(0, MAX_STYLE_FILES);

  const allFindings: HardcodedFinding[] = [];

  let hardcodedFilesProcessed = 0;
  for (let i = 0; i < allStyleFiles.length; i += CONCURRENCY) {
    const batch = allStyleFiles.slice(i, i + CONCURRENCY);
    const results = await Promise.allSettled(
      batch.map(async (file) => {
        const text = await args.fileStore.getText(file.path);
        return text === null ? [] : detectHardcodedValues(text, file.path, tokenLookup);
      })
    );

    const batchHardcoded: HardcodedFinding[] = [];
    for (const result of results) {
      if (result.status === "fulfilled") {
        batchHardcoded.push(...result.value);
      }
    }
    if (batchHardcoded.length) {
      allFindings.push(...batchHardcoded);
      if (args.onIncrementalFindings) {
        await args.onIncrementalFindings({ hardcoded: batchHardcoded });
      }
    }

    hardcodedFilesProcessed += batch.length;
    args.onProgress?.({
      phase: "hardcoded",
      filesProcessed: hardcodedFilesProcessed,
      totalFiles: allStyleFiles.length,
      tokensFound: allTokens.length,
      hardcodedFound: allFindings.length,
    });
  }

  classifyTokenTiers(allTokens, args.provenance);
  const dedupedTokens = deduplicateByIdentity(allTokens);
  const canonicalCount = dedupedTokens.filter(
    (t) => t.tier === "canonical" || t.tier === "unknown"
  ).length;

  return {
    tokenPayload: buildDTCGPayload(dedupedTokens),
    tokenCount: dedupedTokens.length,
    canonicalCount,
    tokenFileCount: tokenFiles.length,
    hardcodedFindings: allFindings,
    duplicateFindings,
    parsedTokens: allTokens,
  };
}

/**
 * Post-process previously extracted tokens with provenance classification.
 *
 * Use this when provenance info becomes available after initial extraction
 * (e.g. after codebase detection runs). Returns a new DTCG payload string
 * and updated counts.
 */
export function refineTokenPayload(
  parsedTokens: ParsedToken[],
  provenance: TokenProvenanceConfig
): { tokenPayload: string; tokenCount: number; canonicalCount: number } {
  classifyTokenTiers(parsedTokens, provenance);
  const deduped = deduplicateByIdentity(parsedTokens);
  const canonicalCount = deduped.filter(
    (t) => t.tier === "canonical" || t.tier === "unknown"
  ).length;
  return {
    tokenPayload: buildDTCGPayload(deduped),
    tokenCount: deduped.length,
    canonicalCount,
  };
}
