/**
 * detect.ts — Rename heuristic + stale-ref classification.
 *
 * Given the tree (existing sections) and the codes extracted from downstream
 * docs (referenced sections), decide for each "ghost" (referenced but absent)
 * whether it has a clear rename match in the tree or should be marked DELETE.
 *
 * Heuristic:
 *   - Code similarity: Levenshtein normalised over max length, < 0.3 → match
 *   - UC overlap: Jaccard similarity of UC heading titles, ≥ 0.5 → match
 *   - A rename is "clear" when exactly one candidate scores and the top score
 *     beats the runner-up by ≥ 0.3. Otherwise → DELETE.
 */
import type {
  ParsedCode,
  ReconcilePlan,
  RenameChange,
  DeletionChange,
  StaleRef,
  SectionKey,
  ScanResult,
} from './types.js';
import { sectionKey } from './types.js';

// ---------------------------------------------------------------------------
// Levenshtein normalised (returns 0..1, lower is more similar)
// ---------------------------------------------------------------------------

export function levenshtein(a: string, b: string): number {
  if (a === b) return 0;
  if (a.length === 0) return b.length;
  if (b.length === 0) return a.length;

  const dp = new Array(b.length + 1);
  for (let j = 0; j <= b.length; j++) dp[j] = j;

  for (let i = 1; i <= a.length; i++) {
    let prev = dp[0];
    dp[0] = i;
    for (let j = 1; j <= b.length; j++) {
      const tmp = dp[j];
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
      prev = tmp;
    }
  }
  return dp[b.length];
}

export function levenshteinNormalized(a: string, b: string): number {
  const m = Math.max(a.length, b.length);
  if (m === 0) return 0;
  return levenshtein(a, b) / m;
}

// ---------------------------------------------------------------------------
// Jaccard similarity over UC heading titles
// ---------------------------------------------------------------------------

export function normalizeTitle(title: string): string {
  return title
    .toLowerCase()
    .normalize('NFD')
    .replace(/[̀-ͯ]/g, '') // strip accents
    .replace(/[^a-z0-9]+/g, ' ')
    .trim();
}

export function jaccardTitles(a: string[], b: string[]): number {
  if (a.length === 0 && b.length === 0) return 0;
  const setA = new Set(a.map(normalizeTitle));
  const setB = new Set(b.map(normalizeTitle));
  if (setA.size === 0 || setB.size === 0) return 0;
  let inter = 0;
  for (const t of setA) if (setB.has(t)) inter++;
  const union = setA.size + setB.size - inter;
  return union === 0 ? 0 : inter / union;
}

// ---------------------------------------------------------------------------
// Rename decision
// ---------------------------------------------------------------------------

const CODE_DIST_THRESHOLD = 0.3;
const JACCARD_THRESHOLD = 0.5;
const GAP_THRESHOLD = 0.3;

interface Candidate {
  arrival: SectionKey;
  score: number;
  reasons: string[];
}

export interface RenameDecision {
  type: 'rename' | 'delete';
  candidate?: SectionKey;
  confidence?: number;
  reason: string;
}

export function decideRename(
  ghost: SectionKey,
  arrivals: SectionKey[],
  titlesByGhost: string[],
  titlesByArrival: Map<SectionKey, string[]>,
): RenameDecision {
  const [, , ghostSec] = ghost.split('/');
  const candidates: Candidate[] = [];

  for (const arrival of arrivals) {
    const [, , arrivalSec] = arrival.split('/');
    const codeDist = levenshteinNormalized(ghostSec, arrivalSec);
    const jaccard = jaccardTitles(
      titlesByGhost,
      titlesByArrival.get(arrival) ?? [],
    );

    const reasons: string[] = [];
    let score = 0;
    if (codeDist < CODE_DIST_THRESHOLD) {
      score = Math.max(score, 1 - codeDist);
      reasons.push('code-similar');
    }
    if (jaccard >= JACCARD_THRESHOLD) {
      score = Math.max(score, jaccard);
      reasons.push('uc-overlap');
    }
    if (reasons.length > 0) {
      candidates.push({ arrival, score, reasons });
    }
  }

  if (candidates.length === 0) {
    return { type: 'delete', reason: 'no-rename-candidate' };
  }

  candidates.sort((a, b) => b.score - a.score);
  const top = candidates[0];
  const second = candidates[1];

  if (!second || top.score - second.score >= GAP_THRESHOLD) {
    return {
      type: 'rename',
      candidate: top.arrival,
      confidence: Math.round(top.score * 100) / 100,
      reason: top.reasons.join('+'),
    };
  }

  return { type: 'delete', reason: 'ambiguous-candidates' };
}

// ---------------------------------------------------------------------------
// Plan builder — turns a scan into RENAME/DELETE/STALE-REF list
// ---------------------------------------------------------------------------

/**
 * From a scan result, build the reconciliation plan.
 *   - ghosts = referenced sections that don't exist in tree
 *   - arrivals = sections in tree that no code references
 *   - for each ghost, decideRename() → RENAME or DELETE
 *   - every code touching a ghost becomes a StaleRef (heading or body)
 */
export function buildPlan(scan: ScanResult): ReconcilePlan {
  const ghosts: SectionKey[] = [];
  for (const ref of scan.referencedSections) {
    if (!scan.existingSections.has(ref)) ghosts.push(ref);
  }

  const arrivals: SectionKey[] = [];
  for (const sec of scan.existingSections) {
    if (!scan.referencedSections.has(sec)) arrivals.push(sec);
  }

  // Build title maps for Jaccard:
  //   ghost titles  → from heading codes of UCs whose triplet matches the ghost
  //   arrival titles → from heading codes of UCs whose triplet matches the arrival
  const titlesBySec = new Map<SectionKey, string[]>();
  for (const codes of scan.codesByFile.values()) {
    for (const c of codes) {
      if (c.kind !== 'UC' || !c.title) continue;
      const key = sectionKey(c.app, c.mod, c.sec);
      const list = titlesBySec.get(key) ?? [];
      list.push(c.title);
      titlesBySec.set(key, list);
    }
  }

  const renames: RenameChange[] = [];
  const deletions: DeletionChange[] = [];

  for (const ghost of ghosts) {
    const [ghostApp, ghostMod] = ghost.split('/');
    // Restrict candidates to the same (APP, MOD) — cross-module renames
    // are vanishingly rare and would produce nonsense matches.
    const sameModArrivals = arrivals.filter((a) => {
      const [aApp, aMod] = a.split('/');
      return aApp === ghostApp && aMod === ghostMod;
    });

    const decision = decideRename(
      ghost,
      sameModArrivals,
      titlesBySec.get(ghost) ?? [],
      titlesBySec,
    );

    const codeCount = countCodesForSection(scan, ghost);

    if (decision.type === 'rename' && decision.candidate) {
      renames.push({
        kind: 'section',
        from: ghost,
        to: decision.candidate,
        confidence: decision.confidence ?? 0,
        reason: decision.reason,
      });
    } else {
      deletions.push({
        kind: 'section',
        path: ghost,
        codeCount,
        reason: decision.reason,
      });
    }
  }

  // staleRefs — every code whose triplet is a ghost
  const ghostSet = new Set(ghosts);
  const staleRefs: StaleRef[] = [];
  for (const codes of scan.codesByFile.values()) {
    for (const c of codes) {
      const key = sectionKey(c.app, c.mod, c.sec);
      if (!ghostSet.has(key)) continue;
      staleRefs.push({
        file: c.file,
        ref: c.full,
        source: c.title ? 'heading' : 'body',
        kind: c.kind,
      });
    }
  }

  return { renames, deletions, staleRefs };
}

function countCodesForSection(scan: ScanResult, key: SectionKey): number {
  let count = 0;
  const [app, mod, sec] = key.split('/');
  for (const codes of scan.codesByFile.values()) {
    for (const c of codes) {
      if (c.app === app && c.mod === mod && c.sec === sec) count++;
    }
  }
  return count;
}
