/**
 * audit-dev-tests — audit.ts
 *
 * Pure audit logic. Reads BA use-case.md files + generated .cs test files,
 * compares the AC sets, emits Findings. No file mutation.
 */

import { readFile } from 'node:fs/promises'
import { relative } from 'node:path'
import { findFiles } from '../../../../lib/fs.js'
import { parseAcFromUseCaseMd } from '../../../testing/cli/scaffold-tests-from-ac/parse-ac.js'
import type { AuditDevTestsSpec, AuditReport, Finding } from './types.js'

/** Compose the global AC ref from a UC code + local id. */
function globalRef(ucCode: string, localId: string): string {
  return `${ucCode}#${localId}`
}

/** Match every `[Trait("AC", "<ref>")]` attribute in a C# source. */
const TRAIT_RE = /\[Trait\(\s*"AC"\s*,\s*"([^"]+)"\s*\)\]/g
/** Match a `// TODO[AC-NN]` marker (the Phase 4 placeholder). */
const TODO_RE = /\/\/\s*TODO\[AC-\d{2}\]/g

// ─── Advisory anti-pattern matchers (DEV-TEST-004..007, severity `warn`) ──────
// Ported from the SmartStack.app test-conventions corpus. These never block the
// gate (they are warnings); the generators are corrected so fresh tests never
// trip them. They catch hand-written drift.

/** `Assert.True(true …)` (C#) or `expect(true).toBe(true)` (Vitest) — a placeholder, not a test. */
const STUB_RE = /Assert\.True\s*\(\s*true\b|expect\s*\(\s*true\s*\)\s*\.toBe\s*\(\s*true\s*\)/
/** A DateTime-ish member compared with BeGreaterThan/BeLessThan — use BeOnOrAfter/BeBefore/BeCloseTo. */
const DATETIME_CMP_RE = /\b\w*(?:At|Date|Time|Utc)\b[^;\n]*\.Should\(\)\s*\.(?:BeGreaterThan|BeLessThan)(?:OrEqualTo)?\s*\(/
/** A hardcoded GUID literal (`new Guid("…")` / `Guid.Parse("…")`) — use Guid.NewGuid() (anti-enumeration). */
const HARDCODED_GUID_RE = /new\s+Guid\s*\(\s*"|Guid\.Parse\s*\(\s*"/
/** `CancellationToken.None` in a test — xUnit v3 wants `TestContext.Current.CancellationToken`. */
const CT_NONE_RE = /CancellationToken\.None\b/

interface BaAcIndex {
  /** Set of global refs declared in BA: `<UC-code>#AC-NN`. */
  refs: Set<string>
  /** Per-ref → the source file that declares it (for messages). */
  source: Map<string, string>
  /** ACs declared per UC, for messages. */
  perUc: Map<string, string[]>
}

async function loadBaAcs(moduleDir: string): Promise<BaAcIndex> {
  const absoluteFiles = await findFiles('**/use-case.md', { cwd: moduleDir })
  const refs = new Set<string>()
  const source = new Map<string, string>()
  const perUc = new Map<string, string[]>()

  for (const absolute of absoluteFiles) {
    const rel = relative(moduleDir, absolute).replace(/\\/g, '/')
    const content = await readFile(absolute, 'utf8')
    const { ucs } = parseAcFromUseCaseMd(content, rel)
    for (const uc of ucs) {
      const ids: string[] = []
      for (const ac of uc.acs) {
        const ref = globalRef(uc.ucCode, ac.localId)
        if (!refs.has(ref)) {
          refs.add(ref)
          source.set(ref, rel)
        }
        ids.push(ref)
      }
      if (ids.length > 0) perUc.set(uc.ucCode, ids)
    }
  }

  return { refs, source, perUc }
}

interface TestIndex {
  /** All `[Trait("AC","…")]` refs found, → source file path (relative). */
  refToFile: Map<string, string>
  /** Files with `// TODO[AC-` markers, → count of markers. */
  todoFiles: Map<string, number>
  /** Total trait count (including any duplicates across files). */
  traitCount: number
}

async function loadTestTraits(projectPath: string): Promise<TestIndex> {
  // Glob every .cs file under Tests/ (any depth). Manually-written non-Acceptance
  // test files MAY carry `[Trait("AC",…)]` to claim AC coverage too — honor that.
  const absoluteFiles = await findFiles('Tests/**/*.cs', { cwd: projectPath })
  const refToFile = new Map<string, string>()
  const todoFiles = new Map<string, number>()
  let traitCount = 0

  for (const absolute of absoluteFiles) {
    const rel = relative(projectPath, absolute).replace(/\\/g, '/')
    const content = await readFile(absolute, 'utf8')

    // Trait matches
    TRAIT_RE.lastIndex = 0
    let m: RegExpExecArray | null
    while ((m = TRAIT_RE.exec(content)) !== null) {
      const ref = m[1]!
      traitCount++
      // First-seen wins; we don't fail on duplicate registrations (a Fact may
      // legitimately repeat the trait — rare but tolerated).
      if (!refToFile.has(ref)) refToFile.set(ref, rel)
    }

    // TODO markers
    TODO_RE.lastIndex = 0
    const todoMatches = content.match(TODO_RE)
    if (todoMatches && todoMatches.length > 0) {
      todoFiles.set(rel, todoMatches.length)
    }
  }

  return { refToFile, todoFiles, traitCount }
}

interface AntiPatternIndex {
  stub: string[]
  datetimeCmp: string[]
  hardcodedGuid: string[]
  ctNone: string[]
}

/**
 * Scan backend (`Tests/**​/*.cs`) and frontend (`tests/**​/*.test.{ts,tsx}`) test
 * files for the advisory anti-patterns. Returns the (sorted) relative file paths
 * tripping each one. The C#-only matchers are skipped for `.ts(x)` files.
 */
async function loadAntiPatterns(projectPath: string): Promise<AntiPatternIndex> {
  const cs = await findFiles('Tests/**/*.cs', { cwd: projectPath })
  const tsx = await findFiles('tests/**/*.test.{ts,tsx}', { cwd: projectPath })
  const stub: string[] = []
  const datetimeCmp: string[] = []
  const hardcodedGuid: string[] = []
  const ctNone: string[] = []

  for (const absolute of [...cs, ...tsx]) {
    const rel = relative(projectPath, absolute).replace(/\\/g, '/')
    const content = await readFile(absolute, 'utf8')
    if (STUB_RE.test(content)) stub.push(rel)
    if (rel.endsWith('.cs')) {
      if (DATETIME_CMP_RE.test(content)) datetimeCmp.push(rel)
      if (HARDCODED_GUID_RE.test(content)) hardcodedGuid.push(rel)
      if (CT_NONE_RE.test(content)) ctNone.push(rel)
    }
  }

  return {
    stub: stub.sort(),
    datetimeCmp: datetimeCmp.sort(),
    hardcodedGuid: hardcodedGuid.sort(),
    ctNone: ctNone.sort(),
  }
}

/** Push a warn-or-ok finding for an advisory anti-pattern rule. */
function pushAntiPattern(findings: Finding[], rule: string, files: string[], what: string, fix: string): void {
  if (files.length > 0) {
    findings.push({ rule, severity: 'warn', message: `${files.length} test file(s) use ${what}.`, refs: files, fix })
  } else {
    findings.push({ rule, severity: 'ok', message: `No ${what} found in test files.`, refs: [] })
  }
}

export async function audit(spec: AuditDevTestsSpec): Promise<AuditReport> {
  const findings: Finding[] = []

  const ba = await loadBaAcs(spec.moduleDir)
  const tests = await loadTestTraits(spec.projectPath)

  // ─── DEV-TEST-001 — every BA AC has a [Fact] ──────────────────────────────
  const missing: string[] = []
  for (const ref of ba.refs) {
    if (!tests.refToFile.has(ref)) missing.push(ref)
  }
  if (missing.length > 0) {
    findings.push({
      rule: 'DEV-TEST-001',
      severity: 'err',
      message: `${missing.length} BA Acceptance Criteria have no generated [Fact] (no [Trait("AC", "<ref>")] found).`,
      refs: missing.sort(),
      fix: `Re-run /ba-develop Phase 4 or scaffold-tests-from-ac --spec '{...}' to emit the missing tests.`,
    })
  } else if (ba.refs.size > 0) {
    findings.push({
      rule: 'DEV-TEST-001',
      severity: 'ok',
      message: `All ${ba.refs.size} BA Acceptance Criteria have a generated [Fact].`,
      refs: [],
    })
  }

  // ─── DEV-TEST-002 — no `// TODO[AC-` marker remains ───────────────────────
  if (tests.todoFiles.size > 0) {
    const totalTodos = Array.from(tests.todoFiles.values()).reduce((a, b) => a + b, 0)
    findings.push({
      rule: 'DEV-TEST-002',
      severity: 'err',
      message: `${totalTodos} '// TODO[AC-' marker(s) remain across ${tests.todoFiles.size} file(s) — Phase 4 TODO-filling pass was incomplete.`,
      refs: Array.from(tests.todoFiles.keys()).sort(),
      fix: `Re-run /ba-develop Phase 4's body-filling subagent for the listed files, or edit each test body to replace 'Assert.Fail(...)' with a real assertion (use the AC text in the // AC: comment as the spec).`,
    })
  } else {
    findings.push({
      rule: 'DEV-TEST-002',
      severity: 'ok',
      message: 'No `// TODO[AC-` marker found in generated test files.',
      refs: [],
    })
  }

  // ─── DEV-TEST-003 — no stale [Trait("AC",…)] references ───────────────────
  const stale: string[] = []
  for (const ref of tests.refToFile.keys()) {
    if (!ba.refs.has(ref)) stale.push(ref)
  }
  if (stale.length > 0) {
    findings.push({
      rule: 'DEV-TEST-003',
      severity: 'warn',
      message: `${stale.length} generated [Trait("AC", "<ref>")] reference(s) do not match any current BA AC (stale).`,
      refs: stale.sort(),
      fix: `Either delete the stale [Fact] in the listed test file, or restore the AC in the upstream use-case.md (via /ba-create-use-case).`,
    })
  } else if (tests.refToFile.size > 0) {
    findings.push({
      rule: 'DEV-TEST-003',
      severity: 'ok',
      message: `All ${tests.refToFile.size} generated [Trait("AC",…)] references resolve to a current BA AC.`,
      refs: [],
    })
  }

  // ─── DEV-TEST-004..007 — advisory anti-pattern checks (warn, never block) ──
  const ap = await loadAntiPatterns(spec.projectPath)
  pushAntiPattern(
    findings, 'DEV-TEST-004', ap.stub,
    'placeholder/stub assertions (`Assert.True(true)` / `expect(true).toBe(true)`)',
    'Replace each with a real assertion that exercises the behaviour under test — a stub gives false coverage.',
  )
  pushAntiPattern(
    findings, 'DEV-TEST-005', ap.datetimeCmp,
    'a DateTime compared with `BeGreaterThan` / `BeLessThan`',
    'Use `BeOnOrAfter` / `BeBefore` / `BeCloseTo(expected, TimeSpan.FromSeconds(n))` on DateTime (FluentAssertions 8.x).',
  )
  pushAntiPattern(
    findings, 'DEV-TEST-006', ap.hardcodedGuid,
    'a hardcoded GUID literal (`new Guid("…")` / `Guid.Parse("…")`)',
    'Use `Guid.NewGuid()` — hardcoded ids invite enumeration and couple tests together.',
  )
  pushAntiPattern(
    findings, 'DEV-TEST-007', ap.ctNone,
    '`CancellationToken.None`',
    'Pass `TestContext.Current.CancellationToken` (xUnit v3).',
  )

  // Top-level success = no err findings (DEV-TEST-004..007 are advisory `warn`).
  const hasErr = findings.some(f => f.severity === 'err')

  return {
    success: !hasErr,
    baAcCount: ba.refs.size,
    testTraitCount: tests.traitCount,
    todoMarkerCount: Array.from(tests.todoFiles.values()).reduce((a, b) => a + b, 0),
    findings,
  }
}

// Exported helpers for unit tests
export { globalRef, loadBaAcs, loadTestTraits, TODO_RE, TRAIT_RE }
