/**
 * detect.test.ts — Heuristique de rename (Levenshtein + Jaccard + decideRename).
 */
import { describe, it, expect } from 'vitest';
import {
  levenshtein,
  levenshteinNormalized,
  normalizeTitle,
  jaccardTitles,
  decideRename,
  buildPlan,
} from '../detect.js';
import type { SectionKey, ScanResult, ParsedCode } from '../types.js';
import { sectionKey } from '../types.js';

// ---------------------------------------------------------------------------
// levenshtein / levenshteinNormalized
// ---------------------------------------------------------------------------

describe('levenshtein', () => {
  it('returns 0 for identical strings', () => {
    expect(levenshtein('abc', 'abc')).toBe(0);
  });

  it('returns full length for one empty string', () => {
    expect(levenshtein('', 'abc')).toBe(3);
    expect(levenshtein('abc', '')).toBe(3);
  });

  it('handles single substitution', () => {
    expect(levenshtein('cat', 'bat')).toBe(1);
  });

  it('handles insertion + deletion mixed', () => {
    expect(levenshtein('kitten', 'sitting')).toBe(3);
  });
});

describe('levenshteinNormalized', () => {
  it('rates very similar codes well below threshold', () => {
    expect(levenshteinNormalized('OPPORTUNITES', 'OPPORTUNITY')).toBeLessThan(0.3);
  });

  it('rates renamed but related code below threshold', () => {
    // CLIENT → CLIENTS is 1 char in 7 → 0.14
    expect(levenshteinNormalized('CLIENT', 'CLIENTS')).toBeLessThan(0.3);
  });

  it('rejects unrelated codes above threshold', () => {
    expect(levenshteinNormalized('OPPORTUNITES', 'PROSPECTS')).toBeGreaterThan(0.3);
  });

  it('returns 0 for empty strings', () => {
    expect(levenshteinNormalized('', '')).toBe(0);
  });
});

// ---------------------------------------------------------------------------
// normalizeTitle / jaccardTitles
// ---------------------------------------------------------------------------

describe('normalizeTitle', () => {
  it('strips accents and lowercases', () => {
    expect(normalizeTitle('Créer une opportunité')).toBe('creer une opportunite');
  });

  it('collapses punctuation to spaces', () => {
    expect(normalizeTitle('Submit: timesheet!')).toBe('submit timesheet');
  });
});

describe('jaccardTitles', () => {
  it('returns 0 for two empty sets', () => {
    expect(jaccardTitles([], [])).toBe(0);
  });

  it('returns 0 for disjoint sets', () => {
    expect(jaccardTitles(['Create A'], ['Delete B'])).toBe(0);
  });

  it('returns 0.5 for 50% overlap', () => {
    const res = jaccardTitles(['X', 'Y', 'Z'], ['X', 'Y', 'W']);
    // intersection 2, union 4 → 0.5
    expect(res).toBe(0.5);
  });

  it('returns 1 for identical sets (with accents normalised)', () => {
    expect(jaccardTitles(['Créer'], ['creer'])).toBe(1);
  });
});

// ---------------------------------------------------------------------------
// decideRename
// ---------------------------------------------------------------------------

describe('decideRename', () => {
  const ghost = sectionKey('CRM', 'PIPELINE', 'OPPORTUNITES');

  it('proposes RENAME when one candidate matches by code similarity', () => {
    const arrivals: SectionKey[] = [sectionKey('CRM', 'PIPELINE', 'OPPORTUNITY')];
    const decision = decideRename(ghost, arrivals, [], new Map());
    expect(decision.type).toBe('rename');
    expect(decision.candidate).toBe(arrivals[0]);
    expect(decision.reason).toContain('code-similar');
  });

  it('proposes RENAME when one candidate matches by UC overlap', () => {
    const arrival = sectionKey('CRM', 'PIPELINE', 'PROSPECTS');
    const titlesByArrival = new Map<SectionKey, string[]>([
      [arrival, ['Créer une opportunité', 'Qualifier le prospect']],
    ]);
    const decision = decideRename(
      ghost,
      [arrival],
      ['Créer une opportunité', 'Qualifier le prospect'],
      titlesByArrival,
    );
    expect(decision.type).toBe('rename');
    expect(decision.candidate).toBe(arrival);
    expect(decision.reason).toContain('uc-overlap');
  });

  it('proposes DELETE when no candidate is close enough', () => {
    const arrival = sectionKey('CRM', 'PIPELINE', 'BILLING_REPORTS');
    const decision = decideRename(ghost, [arrival], [], new Map());
    expect(decision.type).toBe('delete');
    expect(decision.reason).toBe('no-rename-candidate');
  });

  it('proposes DELETE when two candidates are tied (ambiguous)', () => {
    const a = sectionKey('CRM', 'PIPELINE', 'OPPORTUNITY');
    const b = sectionKey('CRM', 'PIPELINE', 'OPPORTUNITIE');
    const decision = decideRename(ghost, [a, b], [], new Map());
    expect(decision.type).toBe('delete');
    expect(decision.reason).toBe('ambiguous-candidates');
  });

  it('proposes DELETE when ghost has no UCs and no code-similar arrival', () => {
    const decision = decideRename(ghost, [], [], new Map());
    expect(decision.type).toBe('delete');
  });
});

// ---------------------------------------------------------------------------
// buildPlan — end-to-end on a synthetic scan
// ---------------------------------------------------------------------------

function code(
  app: string,
  mod: string,
  sec: string,
  num: string,
  title?: string,
): ParsedCode {
  return {
    kind: 'UC',
    app,
    mod,
    sec,
    num,
    full: `UC-${app}-${mod}-${sec}-${num}`,
    file: `/tmp/${app}-${mod}-${sec}.md`,
    title,
  };
}

describe('buildPlan', () => {
  it('returns an empty plan when tree and references match', () => {
    const scan: ScanResult = {
      existingSections: new Set([sectionKey('CRM', 'PIPELINE', 'OPPORTUNITES')]),
      existingModules: new Set(['CRM/PIPELINE']),
      codesByFile: new Map([
        [
          '/tmp/use-case.md',
          [code('CRM', 'PIPELINE', 'OPPORTUNITES', '001', 'Créer')],
        ],
      ]),
      referencedSections: new Set([sectionKey('CRM', 'PIPELINE', 'OPPORTUNITES')]),
    };
    const plan = buildPlan(scan);
    expect(plan.renames).toHaveLength(0);
    expect(plan.deletions).toHaveLength(0);
    expect(plan.staleRefs).toHaveLength(0);
  });

  it('detects a clear rename with code similarity', () => {
    const arrival = sectionKey('CRM', 'PIPELINE', 'OPPORTUNITY');
    const ghost = sectionKey('CRM', 'PIPELINE', 'OPPORTUNITES');
    const scan: ScanResult = {
      existingSections: new Set([arrival]),
      existingModules: new Set(['CRM/PIPELINE']),
      codesByFile: new Map([
        [
          '/tmp/use-case.md',
          [code('CRM', 'PIPELINE', 'OPPORTUNITES', '001', 'Créer')],
        ],
      ]),
      referencedSections: new Set([ghost]),
    };
    const plan = buildPlan(scan);
    expect(plan.renames).toHaveLength(1);
    expect(plan.renames[0].from).toBe(ghost);
    expect(plan.renames[0].to).toBe(arrival);
    expect(plan.deletions).toHaveLength(0);
    expect(plan.staleRefs).toHaveLength(1);
  });

  it('detects a deletion when no arrival matches', () => {
    const ghost = sectionKey('CRM', 'PIPELINE', 'LEGACY');
    const scan: ScanResult = {
      existingSections: new Set<SectionKey>(),
      existingModules: new Set(['CRM/PIPELINE']),
      codesByFile: new Map([
        [
          '/tmp/use-case.md',
          [
            code('CRM', 'PIPELINE', 'LEGACY', '001', 'Old A'),
            code('CRM', 'PIPELINE', 'LEGACY', '002', 'Old B'),
          ],
        ],
      ]),
      referencedSections: new Set([ghost]),
    };
    const plan = buildPlan(scan);
    expect(plan.renames).toHaveLength(0);
    expect(plan.deletions).toHaveLength(1);
    expect(plan.deletions[0].path).toBe(ghost);
    expect(plan.deletions[0].codeCount).toBe(2);
    expect(plan.staleRefs).toHaveLength(2);
  });
});
