/**
 * The one conform engine. Given a code snippet (JSX/TSX inline styles or
 * CSS/SCSS) and a `DesignSystemContext`, it runs the shared fact-IR rule engine
 * (`styles/no-raw-color`, `styles/no-raw-dimensions`) and rewrites hardcoded
 * colors and dimensions that EXACTLY match a token with a known CSS variable to
 * `var(--token)`. It also applies confirmed component mappings from the tenant
 * catalog (`components/prefer-library`) using range-precise JSX edits.
 * Everything else is surfaced as a suggestion — nothing is guessed into the
 * conformed output.
 *
 * Cloud (hosted MCP + autofix), CLI, and the standalone MCP all call this with
 * a context built from their own data source, so results are identical
 * everywhere.
 *
 * Scope today: color + dimension (spacing/radius) tokenization. a11y and
 * component/import swaps are layered on in later slices.
 */
import {
  makeTokenDefinitionFact,
  type ConformChange,
  type ConformCoverage,
  type ConformInput,
  type ConformLocation,
  type ConformResult,
  type ConformSuggestion,
  type ConformToken,
  type DesignSystemContext,
  type Finding,
  type TokenDefinitionFact,
} from "@fragments-sdk/core";
import { scanWithFacts } from "../typestyle/check.js";
import { applyFixes } from "../typestyle/apply-fixes.js";
import { applyComponentConformEdits } from "./component-edits.js";
import { findAccessibilityGaps } from "./a11y-snippet.js";
import {
  buildConformPolicy,
  countUsableComponents,
  COLOR_RULE_ID,
  COMPONENT_RULE_ID,
  TOKEN_RULE_IDS,
} from "./policy.js";

const MAX_SNIPPET_BYTES = 100_000;

type FactCategory = "color" | "spacing" | "radius";

export function conformSnippet(input: ConformInput, context: DesignSystemContext): ConformResult {
  try {
    return runConform(input, context);
  } catch {
    // The engine must never throw or emit altered code on failure.
    return {
      conformed: input.code,
      changed: false,
      summary: "Could not analyze this snippet; returned it unchanged.",
      designSystem: { importsAdded: [] },
      changes: [],
      suggestions: [
        {
          kind: "review-low-confidence",
          confidence: "low",
          message: "Conformance analysis failed for this input; review it manually.",
        },
      ],
      unresolved: [],
    };
  }
}

function runConform(input: ConformInput, context: DesignSystemContext): ConformResult {
  const base: ConformResult = {
    conformed: input.code,
    changed: false,
    summary: "",
    designSystem: { name: context.designSystemName, importsAdded: [] },
    changes: [],
    suggestions: [],
    unresolved: [],
  };

  if (input.code.length > MAX_SNIPPET_BYTES) {
    return {
      ...base,
      summary: "Snippet is too large to analyze (over 100KB).",
      suggestions: [
        {
          kind: "review-low-confidence",
          confidence: "low",
          message: "Split the snippet into smaller pieces and retry.",
        },
      ],
    };
  }

  const tokens = sortTokens(context.tokens.filter(isRelevantToken));
  const componentCount = countUsableComponents(context);
  const coverage: ConformCoverage = {
    tokens: tokens.length,
    components: componentCount,
    evaluated: tokens.length > 0 || componentCount > 0,
  };
  const { facts, authoritative } = buildTokenFacts(tokens);
  const valueIndex = buildValueIndex(tokens);
  const policy = buildConformPolicy(context);

  const ext = resolveExtension(input.filename);
  const filePath = `snippet${ext}`;
  const { findings } = scanWithFacts({
    facts: { governConfig: policy, tokens: facts },
    files: [{ absPath: `/${filePath}`, relPath: filePath, content: input.code }],
  });

  const tokenFindings = findings.filter((f) => TOKEN_RULE_IDS.has(f.ruleId));
  const componentFindings = findings.filter((f) => f.ruleId === COMPONENT_RULE_ID);
  const autoFindings = tokenFindings.filter(
    (f) => f.fix !== undefined && isAuthoritativeFix(f, authoritative)
  );
  const autoComponentFindings = componentFindings.filter((f) => f.fix?.deterministic === true);
  const reviewFindings = tokenFindings.filter((f) => !autoFindings.includes(f));
  const unresolvedComponentFindings = componentFindings.filter(
    (f) => !autoComponentFindings.includes(f)
  );

  const suggestions: ConformSuggestion[] = [];
  const unresolved = unresolvedComponentFindings.map(toUnresolved);
  let conformed = input.code;
  let changes: ConformChange[] = [];
  let importsAdded: string[] = [];
  const deterministicFindings = [...autoFindings, ...autoComponentFindings];

  if (input.apply === "none") {
    changes = deterministicFindings.map(toChange);
  } else if (deterministicFindings.length > 0) {
    const applied = applyFixes({ filePath, source: input.code, findings: deterministicFindings });
    // Safety net: only trust the rewrite if every applied fix actually removed
    // its raw value. If the patcher ever produces a malformed result, decline
    // the rewrite rather than emit broken code.
    if (applicationLooksClean(input.code, applied.source, applied.applied)) {
      conformed = applied.source;
      changes = applied.applied.map(toChange);
      const componentEdits = applyComponentConformEdits({
        source: conformed,
        findings: applied.applied,
      });
      conformed = componentEdits.source;
      changes.push(...componentEdits.changes);
      suggestions.push(...componentEdits.suggestions);
      importsAdded = componentEdits.importsAdded;
    } else {
      for (const finding of applied.applied) {
        suggestions.push(skippedToSuggestion(finding));
      }
    }
    for (const skip of applied.skipped) {
      suggestions.push(skippedToSuggestion(skip.finding));
    }
  }

  for (const finding of reviewFindings) {
    suggestions.push(toSuggestion(finding, valueIndex));
  }

  if (isJsxExtension(ext)) {
    suggestions.push(...findAccessibilityGaps(input.code));
  }

  const changed = conformed !== input.code;
  return {
    ...base,
    conformed,
    changed,
    designSystem: {
      name: context.designSystemName,
      importsAdded,
    },
    changes,
    suggestions,
    unresolved,
    coverage,
    summary: summarize(changes, suggestions.length, unresolved.length, context),
  };
}

/* ------------------------------- tokens -------------------------------- */

function isRelevantToken(token: ConformToken): boolean {
  return factCategory(token) !== null;
}

/** Map a context token's category to the fact category the rules query by. */
function factCategory(token: ConformToken): FactCategory | null {
  const category = (token.category ?? "").toLowerCase();
  if (category === "color" || category.includes("color")) return "color";
  if (category.includes("radius") || category.includes("radii")) return "radius";
  if (category === "spacing" || category.includes("space")) return "spacing";
  return null;
}

/** Collapse fact categories to the value-normalization domain. */
function valueDomain(category: FactCategory): "color" | "dimension" {
  return category === "color" ? "color" : "dimension";
}

function normalizeForCategory(value: string, category: FactCategory): string | null {
  return category === "color" ? normalizeColor(value) : normalizeDimension(value);
}

const TIER_RANK: Record<string, number> = { canonical: 0, extended: 1, unknown: 2 };

function sortTokens(tokens: ConformToken[]): ConformToken[] {
  return [...tokens].sort((a, b) => {
    const rankA = TIER_RANK[a.tier ?? "unknown"] ?? 2;
    const rankB = TIER_RANK[b.tier ?? "unknown"] ?? 2;
    if (rankA !== rankB) return rankA - rankB;
    return tokenKey(a).localeCompare(tokenKey(b));
  });
}

function tokenKey(token: ConformToken): string {
  return token.label ?? token.cssVar ?? token.value;
}

function buildTokenFacts(tokens: ConformToken[]): {
  facts: TokenDefinitionFact[];
  authoritative: Set<string>;
} {
  // Collect the distinct (category, value) pairs claimed by each CSS variable.
  const valuesByVar = new Map<string, Set<string>>();
  const firstByVar = new Map<string, { token: ConformToken; category: FactCategory }>();
  for (const token of tokens) {
    const category = factCategory(token);
    const cssVar = token.cssVar;
    if (!category || !cssVar) continue;
    const norm = normalizeForCategory(token.value, category);
    if (norm === null) continue;
    if (!valuesByVar.has(cssVar)) {
      valuesByVar.set(cssVar, new Set());
      firstByVar.set(cssVar, { token, category });
    }
    valuesByVar.get(cssVar)?.add(`${category}:${norm}`);
  }

  const facts: TokenDefinitionFact[] = [];
  const authoritative = new Set<string>();
  for (const [cssVar, values] of valuesByVar) {
    // A variable that maps to more than one distinct value is ambiguous — we
    // cannot know which it resolves to at runtime, so never auto-apply.
    if (values.size !== 1) continue;
    const entry = firstByVar.get(cssVar);
    if (!entry) continue;
    authoritative.add(cssVar);
    facts.push(
      makeTokenDefinitionFact({
        name: cssVar,
        value: entry.token.value,
        category: entry.category,
      })
    );
  }
  return { facts, authoritative };
}

/** Index tokens by `${domain}:${normalizedValue}` for suggestion enrichment. */
function buildValueIndex(tokens: ConformToken[]): Map<string, ConformToken> {
  const byValue = new Map<string, ConformToken>();
  for (const token of tokens) {
    const category = factCategory(token);
    if (!category) continue;
    const norm = normalizeForCategory(token.value, category);
    if (norm === null) continue;
    const key = `${valueDomain(category)}:${norm}`;
    if (!byValue.has(key)) byValue.set(key, token);
  }
  return byValue;
}

function isAuthoritativeFix(finding: Finding, authoritative: Set<string>): boolean {
  const fix = finding.fix;
  if (!fix || fix.kind !== "replaceStyleValue") return false;
  const cssVars = extractVarNames(fix.value);
  return cssVars.length > 0 && cssVars.every((cssVar) => authoritative.has(cssVar));
}

/**
 * Trust the rewrite only if it changed the source and every applied fix
 * strictly reduced the count of its raw value (i.e. the raw value was actually
 * removed, not just had a token spliced in beside it).
 */
function applicationLooksClean(
  original: string,
  conformed: string,
  applied: readonly Finding[]
): boolean {
  if (applied.length > 0 && conformed === original) return false;
  for (const finding of applied) {
    const before = stringAttr(finding.attributes, "rawValue");
    if (!before) continue;
    // When the raw value is echoed inside the replacement (e.g. "0" inside
    // "var(--space-0)"), a global occurrence count can't strictly decrease —
    // trust the patcher rather than reject a correct fix.
    const after = finding.fix?.kind === "replaceStyleValue" ? finding.fix.value : undefined;
    if (after && after.includes(before)) continue;
    if (countOccurrences(conformed, before) >= countOccurrences(original, before)) {
      return false;
    }
  }
  return true;
}

function countOccurrences(haystack: string, needle: string): number {
  if (!needle) return 0;
  let count = 0;
  let index = haystack.indexOf(needle);
  while (index !== -1) {
    count += 1;
    index = haystack.indexOf(needle, index + needle.length);
  }
  return count;
}

/* ------------------------------- mapping ------------------------------- */

function toChange(finding: Finding): ConformChange {
  const fix = finding.fix;
  if (fix?.kind === "replaceComponent") {
    return {
      kind: "swap-component",
      ruleId: finding.ruleId,
      code: finding.code,
      severity: levelToSeverity(finding.level),
      message: finding.message,
      before: `<${fix.from}>`,
      after: `<${fix.to}>`,
      canonical: stringAttr(finding.attributes, "suggestedComponent") ?? fix.to,
      confidence: "high",
      location: toLocation(finding.location),
    };
  }
  if (fix?.kind === "replaceImport") {
    return {
      kind: "rewrite-import",
      ruleId: finding.ruleId,
      code: finding.code,
      severity: levelToSeverity(finding.level),
      message: finding.message,
      before: fix.from,
      after: fix.to,
      canonical: stringAttr(finding.attributes, "suggestedComponent"),
      confidence: "high",
      location: toLocation(finding.location),
    };
  }
  const after = fix && fix.kind === "replaceStyleValue" ? fix.value : undefined;
  return {
    kind: "replace-token",
    ruleId: finding.ruleId,
    code: finding.code,
    severity: levelToSeverity(finding.level),
    message: finding.message,
    before: stringAttr(finding.attributes, "rawValue"),
    after,
    confidence: "high",
    location: toLocation(finding.location),
  };
}

function toUnresolved(finding: Finding) {
  return {
    element: stringAttr(finding.attributes, "rawValue"),
    canonical: stringAttr(finding.attributes, "suggestedComponent"),
    reason: finding.message,
    location: toLocation(finding.location),
  };
}

function toSuggestion(finding: Finding, valueIndex: Map<string, ConformToken>): ConformSuggestion {
  const raw = stringAttr(finding.attributes, "rawValue");
  const domain: "color" | "dimension" = finding.ruleId === COLOR_RULE_ID ? "color" : "dimension";
  let match: ConformToken | undefined;
  if (raw) {
    const norm = domain === "color" ? normalizeColor(raw) : normalizeDimension(raw);
    if (norm !== null) match = valueIndex.get(`${domain}:${norm}`);
  }
  const noun = domain === "color" ? "color" : "dimension";
  const hint = match
    ? `Matches your "${tokenKey(match)}" token; record its CSS variable to enable an automatic fix.`
    : `No design token matches this ${noun}.`;
  return {
    kind: "review-low-confidence",
    message: `Hardcoded ${noun} ${raw ? `"${raw}" ` : ""}is not referenced through a design token.`,
    hint,
    confidence: "medium",
    location: toLocation(finding.location),
  };
}

function skippedToSuggestion(finding: Finding): ConformSuggestion {
  const raw = stringAttr(finding.attributes, "rawValue") ?? "a value";
  return {
    kind: "review-low-confidence",
    message: `Could not safely apply the token fix for ${raw}; apply it manually.`,
    confidence: "low",
    location: toLocation(finding.location),
  };
}

/* ------------------------------- helpers ------------------------------- */

function summarize(
  changes: ConformChange[],
  suggestionCount: number,
  unresolvedCount: number,
  context: DesignSystemContext
): string {
  if (changes.length === 0 && suggestionCount === 0 && unresolvedCount === 0) {
    return "No design-system issues found in this snippet.";
  }
  const dsName = context.designSystemName ?? "design-system";
  const byKind = (kind: ConformChange["kind"]) =>
    changes.filter((change) => change.kind === kind).length;
  const tokenCount = byKind("replace-token") + byKind("add-fallback");
  const swapCount = byKind("swap-component");
  const importCount = byKind("rewrite-import");
  const renameCount = byKind("rename-prop");

  const parts: string[] = [];
  if (tokenCount > 0) {
    parts.push(`mapped ${tokenCount} hardcoded value${plural(tokenCount)} to ${dsName} tokens`);
  }
  if (swapCount > 0) {
    parts.push(`swapped ${swapCount} element${plural(swapCount)} to ${dsName} components`);
  }
  if (importCount > 0) {
    parts.push(`rewrote ${importCount} import${plural(importCount)}`);
  }
  if (renameCount > 0) {
    parts.push(`renamed ${renameCount} prop${plural(renameCount)}`);
  }
  if (suggestionCount > 0) {
    parts.push(`${suggestionCount} item${plural(suggestionCount)} to review`);
  }
  if (unresolvedCount > 0) {
    parts.push(`${unresolvedCount} unresolved component issue${plural(unresolvedCount)}`);
  }
  const sentence = parts.join("; ");
  return `${sentence.charAt(0).toUpperCase()}${sentence.slice(1)}.`;
}

function plural(count: number): string {
  return count === 1 ? "" : "s";
}

function resolveExtension(filename?: string): string {
  if (filename) {
    const lower = filename.toLowerCase();
    if (lower.endsWith(".scss")) return ".scss";
    if (lower.endsWith(".css")) return ".css";
  }
  return ".tsx";
}

function isJsxExtension(ext: string): boolean {
  return ext === ".tsx" || ext === ".jsx";
}

function levelToSeverity(level: unknown): "error" | "warning" | "info" {
  if (level === "error") return "error";
  if (level === "warn") return "warning";
  return "info";
}

function toLocation(location: Finding["location"]): ConformLocation | undefined {
  if (!location) return undefined;
  return {
    line: location.line,
    column: location.column,
    endLine: location.endLine,
    endColumn: location.endColumn,
  };
}

function stringAttr(
  attributes: Record<string, unknown> | undefined,
  key: string
): string | undefined {
  const value = attributes?.[key];
  return typeof value === "string" ? value : undefined;
}

function extractVarName(value: string): string | null {
  const match = /^var\(\s*(--[A-Za-z0-9_-]+)\s*\)$/.exec(value.trim());
  return match ? match[1] : null;
}

function extractVarNames(value: string): string[] {
  const parts = value.trim().split(/\s+/);
  if (parts.length === 0) return [];
  const vars: string[] = [];
  for (const part of parts) {
    const cssVar = extractVarName(part);
    if (!cssVar) return [];
    vars.push(cssVar);
  }
  return vars;
}

function normalizeColor(value: string): string {
  const lower = value.trim().toLowerCase();
  const hex3 = /^#([0-9a-f]{3})$/.exec(lower);
  if (hex3) {
    const [r, g, b] = hex3[1];
    return `#${r}${r}${g}${g}${b}${b}`;
  }
  const hex4 = /^#([0-9a-f]{4})$/.exec(lower);
  if (hex4) {
    const [r, g, b, a] = hex4[1];
    return `#${r}${r}${g}${g}${b}${b}${a}${a}`;
  }
  return lower;
}

/**
 * Canonical match key `${amount}${unit}` (unitless treated as px). Mirrors the
 * core rule: units are not cross-converted, so `1rem` never matches a `16px`
 * token (no 16px-root assumption).
 */
function normalizeDimension(value: string): string | null {
  const match = /^(-?\d+(?:\.\d+)?)(px|rem|em)?$/.exec(value.trim().toLowerCase());
  if (!match) return null;
  const amount = Number.parseFloat(match[1]);
  if (!Number.isFinite(amount)) return null;
  return `${amount}${match[2] ?? "px"}`;
}
