/**
 * Source patcher for deterministic governance fixes.
 *
 * Pure-on-strings: takes a file's source plus the findings against it and
 * returns a patched source. Never reads or writes the filesystem.
 *
 * Patch strategy is range-precise: each fix resolves to a `[start, end)`
 * character offset in the original source and is applied by string slicing.
 * Edits are sorted descending so earlier offsets stay valid as later edits
 * mutate the tail. Babel/AST parsing is only used to *find* the offsets; the
 * source itself is never re-generated, so unrelated whitespace and comments
 * are preserved byte-for-byte.
 */
import { parse, type ParserOptions } from "@babel/parser";
import _traverse, { type NodePath } from "@babel/traverse";
import * as t from "@babel/types";

import type { Finding, FindingFix } from "@fragments-sdk/core";

// `@babel/traverse` is published as CJS; ESM resolution gives `{ default: fn }`.
const traverse = (_traverse as unknown as { default?: typeof _traverse }).default ?? _traverse;

const BABEL_PARSER_OPTIONS: ParserOptions = {
  sourceType: "module",
  plugins: [
    "jsx",
    "typescript",
    ["decorators", { decoratorsBeforeExport: true }],
    "classProperties",
    "classPrivateProperties",
    "classPrivateMethods",
    "exportDefaultFrom",
    "exportNamespaceFrom",
    "dynamicImport",
    "nullishCoalescingOperator",
    "optionalChaining",
    "objectRestSpread",
  ],
};

export interface ApplyFixesInput {
  filePath: string;
  source: string;
  findings: readonly Finding[];
}

export interface SkippedFix {
  finding: Finding;
  reason: string;
}

export interface ApplyFixesResult {
  /** New source with all applicable fixes spliced in. */
  source: string;
  /** Findings whose fix was successfully spliced. */
  applied: Finding[];
  /** Findings whose fix could not be applied (with the reason). */
  skipped: SkippedFix[];
}

export interface ResolvedFixEdit {
  start: number;
  end: number;
  replacement: string;
  finding: Finding;
}

export interface ResolveFixEditsInput {
  filePath: string;
  source: string;
  findings: readonly Finding[];
}

export interface ResolveFixEditsResult {
  edits: ResolvedFixEdit[];
  skipped: SkippedFix[];
}

export function applyFixes(input: ApplyFixesInput): ApplyFixesResult {
  const resolved = resolveFixEdits(input);
  return finalize(input.source, resolved.edits, resolved.skipped);
}

export function resolveFixEdits(input: ResolveFixEditsInput): ResolveFixEditsResult {
  const { filePath, source, findings } = input;
  const applicable = findings.filter((f) => f.fix !== undefined);

  if (applicable.length === 0) {
    return { edits: [], skipped: [] };
  }

  if (isJsxFile(filePath)) {
    return resolveJsxFixEdits(filePath, source, applicable);
  }
  if (isStyleFile(filePath)) {
    return resolveStyleFixEdits(filePath, source, applicable);
  }
  return {
    edits: [],
    skipped: applicable.map((f) => ({
      finding: f,
      reason: `unsupported file extension for ${filePath}`,
    })),
  };
}

// ---------------------------------------------------------------------------
// JSX/TSX fixes
// ---------------------------------------------------------------------------

function resolveJsxFixEdits(
  filePath: string,
  source: string,
  findings: readonly Finding[]
): ResolveFixEditsResult {
  let ast: t.File;
  try {
    ast = parse(source, BABEL_PARSER_OPTIONS);
  } catch (err) {
    const reason = `failed to parse ${filePath}: ${(err as Error).message}`;
    return {
      edits: [],
      skipped: findings.map((f) => ({ finding: f, reason })),
    };
  }

  const elementsByLocation = new Map<string, NodePath<t.JSXElement>>();
  const importDeclarations: Array<NodePath<t.ImportDeclaration>> = [];
  traverse(ast, {
    ImportDeclaration(path) {
      importDeclarations.push(path);
    },
    JSXElement(path) {
      const opening = path.node.openingElement;
      const loc = opening.loc;
      if (!loc) return;
      const key = `${loc.start.line}:${loc.start.column}`;
      // Only record the first match per (line, column). Two elements at the
      // same start position would be a Babel bug anyway.
      if (!elementsByLocation.has(key)) elementsByLocation.set(key, path);
    },
  });

  const edits: ResolvedFixEdit[] = [];
  const skipped: SkippedFix[] = [];

  for (const finding of findings) {
    const fix = finding.fix;
    if (!fix) continue;

    if (fix.kind === "replaceImport") {
      const edit = buildImportEdit(importDeclarations, fix, finding);
      if (typeof edit === "string") {
        skipped.push({ finding, reason: edit });
        continue;
      }
      edits.push(edit);
      continue;
    }

    const key = `${finding.location.line}:${finding.location.column}`;
    const path = elementsByLocation.get(key);
    if (!path) {
      skipped.push({
        finding,
        reason: `no JSX element at ${filePath}:${finding.location.line}:${finding.location.column}`,
      });
      continue;
    }

    const edit = buildJsxEdit(path, fix, finding);
    if (typeof edit === "string") {
      skipped.push({ finding, reason: edit });
      continue;
    }
    edits.push(...edit);
  }

  return { edits, skipped };
}

function buildImportEdit(
  paths: Array<NodePath<t.ImportDeclaration>>,
  fix: Extract<FindingFix, { kind: "replaceImport" }>,
  finding: Finding
): ResolvedFixEdit | string {
  const matches = paths.filter((path) => path.node.source.value === fix.from);
  const atFindingLocation = matches.filter((path) => {
    const loc = path.node.loc?.start;
    return loc?.line === finding.location.line && loc.column === finding.location.column;
  });
  const candidates = atFindingLocation.length > 0 ? atFindingLocation : matches;
  if (candidates.length === 0) {
    return `import source \`${fix.from}\` not found`;
  }
  if (candidates.length > 1) {
    return `multiple import sources matched \`${fix.from}\``;
  }
  return rangeEdit(candidates[0].node.source, JSON.stringify(fix.to), finding);
}

function buildJsxEdit(
  path: NodePath<t.JSXElement>,
  fix: FindingFix,
  finding: Finding
): ResolvedFixEdit[] | string {
  if (fix.kind === "replacePropValue") {
    const attr = findJsxAttribute(path, fix.prop);
    if (!attr) return `prop \`${fix.prop}\` not found on JSX element`;
    const edit = jsxAttributeValueEdit(attr, fix.value, finding);
    return typeof edit === "string" ? edit : [edit];
  }

  if (fix.kind === "replaceStyleValue") {
    const styleAttr = findJsxAttribute(path, "style");
    if (!styleAttr) return `no \`style\` attribute on JSX element`;
    const property = findInlineStyleProperty(styleAttr, fix.property);
    if (!property) return `inline style property \`${fix.property}\` not found`;
    const edit = inlineStyleValueEdit(property, fix.value, finding);
    return typeof edit === "string" ? edit : [edit];
  }

  if (fix.kind === "replaceComponent") {
    return jsxComponentNameEdits(path, fix, finding);
  }

  return `unsupported fix kind for JSX: ${(fix as { kind: string }).kind}`;
}

function jsxComponentNameEdits(
  path: NodePath<t.JSXElement>,
  fix: Extract<FindingFix, { kind: "replaceComponent" }>,
  finding: Finding
): ResolvedFixEdit[] | string {
  const openingName = path.node.openingElement.name;
  if (!t.isJSXIdentifier(openingName)) {
    return `component replacement only supports simple JSX identifiers`;
  }
  if (openingName.name !== fix.from) {
    return `JSX element is \`${openingName.name}\`, not \`${fix.from}\``;
  }
  const openingEdit = rangeEdit(openingName, fix.to, finding);
  if (typeof openingEdit === "string") return openingEdit;

  const closingName = path.node.closingElement?.name;
  if (!closingName) return [openingEdit];
  if (!t.isJSXIdentifier(closingName)) {
    return `component replacement only supports simple JSX closing identifiers`;
  }
  if (closingName.name !== fix.from) {
    return `JSX closing element is \`${closingName.name}\`, not \`${fix.from}\``;
  }
  const closingEdit = rangeEdit(closingName, fix.to, finding);
  if (typeof closingEdit === "string") return closingEdit;
  return [openingEdit, closingEdit];
}

function findJsxAttribute(path: NodePath<t.JSXElement>, name: string): t.JSXAttribute | null {
  for (const attr of path.node.openingElement.attributes) {
    if (!t.isJSXAttribute(attr)) continue;
    const attrName = attr.name;
    if (t.isJSXIdentifier(attrName) && attrName.name === name) return attr;
    if (t.isJSXNamespacedName(attrName)) {
      const full = `${attrName.namespace.name}:${attrName.name.name}`;
      if (full === name) return attr;
    }
  }
  return null;
}

function jsxAttributeValueEdit(
  attr: t.JSXAttribute,
  value: unknown,
  finding: Finding
): ResolvedFixEdit | string {
  const attrValue = attr.value;
  if (attrValue === null || attrValue === undefined) {
    return "JSX boolean prop has no value to replace";
  }

  // Common shapes:
  //   foo="bar"     → StringLiteral
  //   foo={"bar"}   → JSXExpressionContainer{StringLiteral}
  //   foo={1}       → JSXExpressionContainer{NumericLiteral}
  //   foo={true}    → JSXExpressionContainer{BooleanLiteral}
  if (t.isStringLiteral(attrValue)) {
    if (typeof value !== "string") {
      return `cannot place non-string value into a string-literal JSX prop`;
    }
    return rangeEdit(attrValue, JSON.stringify(value), finding);
  }

  if (t.isJSXExpressionContainer(attrValue)) {
    const inner = attrValue.expression;
    const replacement = renderJsLiteral(value);
    if (replacement === null) {
      return `unsupported replacement value for JSX expression`;
    }
    if (
      t.isStringLiteral(inner) ||
      t.isNumericLiteral(inner) ||
      t.isBooleanLiteral(inner) ||
      t.isNullLiteral(inner)
    ) {
      return rangeEdit(inner, replacement, finding);
    }
    return `JSX prop has a complex expression that is not safe to rewrite`;
  }

  return `unsupported JSX attribute value shape`;
}

function findInlineStyleProperty(
  styleAttr: t.JSXAttribute,
  property: string
): t.ObjectProperty | null {
  const value = styleAttr.value;
  if (!value || !t.isJSXExpressionContainer(value)) return null;
  const expr = value.expression;
  if (!t.isObjectExpression(expr)) return null;
  for (const prop of expr.properties) {
    if (!t.isObjectProperty(prop)) continue;
    const key = prop.key;
    if (t.isIdentifier(key) && key.name === property) return prop;
    if (t.isStringLiteral(key) && key.value === property) return prop;
  }
  return null;
}

function inlineStyleValueEdit(
  prop: t.ObjectProperty,
  raw: string,
  finding: Finding
): ResolvedFixEdit | string {
  const value = prop.value;
  // Match the original literal kind so a numeric `padding: 13` stays numeric.
  // Anything that doesn't parse cleanly as a number falls back to a string.
  if (t.isNumericLiteral(value)) {
    const asNumber = Number(raw);
    if (Number.isFinite(asNumber)) {
      return rangeEdit(value, String(asNumber), finding);
    }
    return rangeEdit(value, JSON.stringify(raw), finding);
  }
  if (t.isStringLiteral(value)) {
    return rangeEdit(value, JSON.stringify(raw), finding);
  }
  if (t.isTemplateLiteral(value) && value.quasis.length === 1) {
    return rangeEdit(value, JSON.stringify(raw), finding);
  }
  if (t.isCallExpression(value) && isVarCall(value)) {
    return rangeEdit(value, JSON.stringify(raw), finding);
  }
  return `inline style value is not a literal — cannot rewrite`;
}

function isVarCall(call: t.CallExpression): boolean {
  return (
    t.isIdentifier(call.callee, { name: "var" }) &&
    call.arguments.length >= 1 &&
    t.isStringLiteral(call.arguments[0])
  );
}

function renderJsLiteral(value: unknown): string | null {
  if (typeof value === "string") return JSON.stringify(value);
  if (typeof value === "number" && Number.isFinite(value)) return String(value);
  if (typeof value === "boolean") return value ? "true" : "false";
  if (value === null) return "null";
  return null;
}

// ---------------------------------------------------------------------------
// CSS / SCSS fixes
// ---------------------------------------------------------------------------

function resolveStyleFixEdits(
  filePath: string,
  source: string,
  findings: readonly Finding[]
): ResolveFixEditsResult {
  const lineStarts = buildLineStarts(source);
  const edits: ResolvedFixEdit[] = [];
  const skipped: SkippedFix[] = [];

  for (const finding of findings) {
    const fix = finding.fix;
    if (!fix) continue;
    if (fix.kind !== "replaceStyleValue") {
      skipped.push({
        finding,
        reason: `unsupported fix kind for style file: ${fix.kind}`,
      });
      continue;
    }

    const propertyOffset = offsetFromLineCol(
      lineStarts,
      finding.location.line,
      finding.location.column
    );
    if (propertyOffset === null) {
      skipped.push({
        finding,
        reason: `location ${finding.location.line}:${finding.location.column} out of bounds`,
      });
      continue;
    }

    const range = findCssValueRange(source, propertyOffset, fix.property);
    if (!range) {
      skipped.push({
        finding,
        reason: `could not locate \`${fix.property}\` declaration value`,
      });
      continue;
    }

    edits.push({
      start: range.start,
      end: range.end,
      replacement: fix.value,
      finding,
    });
  }

  return { edits, skipped };
}

interface CssValueRange {
  start: number;
  end: number;
}

/**
 * Starting at `propertyOffset` (which the extractor anchored at the property
 * name), advance through `property`, optional whitespace, the `:`, and then
 * leading whitespace, and return the byte range of the value up to the next
 * `;` or block boundary.
 */
function findCssValueRange(
  source: string,
  propertyOffset: number,
  property: string
): CssValueRange | null {
  let i = propertyOffset;
  // Verify the property name matches at this offset (extractor anchors here).
  if (source.slice(i, i + property.length) !== property) return null;
  i += property.length;
  // Skip whitespace until ':'.
  while (i < source.length && /\s/.test(source[i])) i++;
  if (source[i] !== ":") return null;
  i++; // past ':'
  // Skip leading whitespace before the value, including a newline when the
  // value is wrapped onto the next line (otherwise the value range collapses
  // to zero width and the fix is spliced in without removing the raw value).
  while (i < source.length && /\s/.test(source[i])) i++;
  const start = i;

  // Read until ';', '\n}', '}', or end of file. Respect quoted strings and
  // parenthesized groups so values like `rgb(0, 0, 0)` aren't truncated.
  let depth = 0;
  while (i < source.length) {
    const ch = source[i];
    if (ch === '"' || ch === "'") {
      i = skipString(source, i, ch);
      continue;
    }
    if (ch === "(") {
      depth++;
      i++;
      continue;
    }
    if (ch === ")") {
      depth = Math.max(0, depth - 1);
      i++;
      continue;
    }
    if (depth === 0 && (ch === ";" || ch === "}" || ch === "\n")) break;
    i++;
  }

  // Trim trailing whitespace before the terminator.
  let end = i;
  while (end > start && /\s/.test(source[end - 1])) end--;

  return { start, end };
}

function skipString(src: string, i: number, quote: string): number {
  i++; // past opening quote
  while (i < src.length) {
    const ch = src[i];
    if (ch === "\\") {
      i += 2;
      continue;
    }
    if (ch === quote) return i + 1;
    i++;
  }
  return i;
}

// ---------------------------------------------------------------------------
// Shared range edit helpers
// ---------------------------------------------------------------------------

function rangeEdit(node: t.Node, replacement: string, finding: Finding): ResolvedFixEdit | string {
  if (typeof node.start !== "number" || typeof node.end !== "number") {
    return `node has no source range`;
  }
  return { start: node.start, end: node.end, replacement, finding };
}

function finalize(
  source: string,
  edits: ResolvedFixEdit[],
  skipped: SkippedFix[]
): ApplyFixesResult {
  // Apply latest-first so offsets in earlier edits stay valid.
  const sorted = edits.slice().sort((a, b) => b.start - a.start);
  const overlaps = detectOverlaps(sorted);
  if (overlaps.length > 0) {
    // Drop overlapping edits — they're indeterminate. Leave them as skipped so
    // the user sees what was bypassed.
    const overlapSet = new Set(overlaps);
    const kept = sorted.filter((e) => !overlapSet.has(e));
    let next = source;
    for (const edit of kept) {
      next = next.slice(0, edit.start) + edit.replacement + next.slice(edit.end);
    }
    return {
      source: next,
      applied: uniqueFindingsForEdits(kept),
      skipped: [
        ...skipped,
        ...overlaps.map((e) => ({
          finding: e.finding,
          reason: "edit overlaps another fix",
        })),
      ],
    };
  }

  let next = source;
  for (const edit of sorted) {
    next = next.slice(0, edit.start) + edit.replacement + next.slice(edit.end);
  }
  return { source: next, applied: uniqueFindingsForEdits(sorted), skipped };
}

function uniqueFindingsForEdits(edits: ResolvedFixEdit[]): Finding[] {
  const seen = new Set<string>();
  const findings: Finding[] = [];
  for (const edit of edits) {
    if (seen.has(edit.finding.fingerprint)) continue;
    seen.add(edit.finding.fingerprint);
    findings.push(edit.finding);
  }
  return findings;
}

function detectOverlaps(sortedDescending: ResolvedFixEdit[]): ResolvedFixEdit[] {
  const out: ResolvedFixEdit[] = [];
  // sortedDescending: edit[0] has the largest start. Walk pairwise.
  for (let i = 0; i < sortedDescending.length - 1; i++) {
    const cur = sortedDescending[i];
    const next = sortedDescending[i + 1];
    if (next.end > cur.start) {
      out.push(cur, next);
    }
  }
  // De-duplicate while preserving order.
  return Array.from(new Set(out));
}

// ---------------------------------------------------------------------------
// File / offset helpers
// ---------------------------------------------------------------------------

function isJsxFile(filePath: string): boolean {
  return /\.(tsx|jsx|ts|js|mjs|cjs)$/i.test(filePath);
}

function isStyleFile(filePath: string): boolean {
  return /\.(s?css|less)$/i.test(filePath);
}

function buildLineStarts(content: string): number[] {
  const starts = [0];
  for (let i = 0; i < content.length; i++) {
    if (content.charCodeAt(i) === 10 /* \n */) starts.push(i + 1);
  }
  return starts;
}

function offsetFromLineCol(lineStarts: number[], line: number, column: number): number | null {
  if (line < 1 || line > lineStarts.length) return null;
  const start = lineStarts[line - 1];
  return start + column;
}
