/**
 * cli:extract-doc — extract.ts
 *
 * The deterministic extraction engine. All file IO is async (lib/fs) and
 * NEVER throws on missing sources — it returns nulls/empties and pushes a
 * human-readable note into `warnings`, so a partially-scaffolded project still
 * yields a useful report.
 *
 * Replaces the legacy repo-root scripts (extract-api-endpoints.ts /
 * extract-business-rules.ts) — same parsing heuristics, but layer locations
 * come from lib/detector (findSmartStackStructure) instead of hardcoded
 * `SmartStack.Api` / `SmartStack.Domain` paths, so it works on any generated
 * client project (MyApp.Api, MyApp.Domain, …).
 */
import path from 'node:path'
import { findSmartStackStructure, type SmartStackStructure } from '../../../lib/detector.js'
import { extractNavRoutes } from '../../../lib/navroute-parser.js'
import { findFiles, readText, fileExists, directoryExists } from '../../../lib/fs.js'
import { toPascalCase, toKebabCase, singularize, pluralize, capitalize } from '../../../lib/string-utils.js'
import type {
  ExtractDocInput,
  ExtractReport,
  ChartInfo,
  ApiEndpoint,
  BusinessRule,
  EntityProperty,
  ExistingDocReport,
  ForbiddenSection,
  OverflowRisk,
} from './types.js'

const RECHARTS_MOCK: Record<string, string> = {
  PieChart: 'donut/pie — SVG circle stroke-dasharray or CSS conic-gradient',
  Pie: 'donut/pie — SVG circle stroke-dasharray or CSS conic-gradient',
  Doughnut: 'donut — SVG circle stroke-dasharray',
  LineChart: 'line — SVG polyline / path',
  AreaChart: 'area — SVG path with fill',
  RadialBarChart: 'radial bar — SVG circle stroke-dasharray',
}

export async function extract(input: ExtractDocInput, warnings: string[]): Promise<ExtractReport> {
  const { type, target, application, projectPath } = input
  const structure = await findSmartStackStructure(projectPath)

  const pascal = toPascalCase(target)
  const singular = capitalize(singularize(target))
  const namespace = `docs${application ? toPascalCase(application) : ''}${pascal}`

  const report: ExtractReport = {
    type,
    target,
    application: application ?? null,
    namespace,
    resolved: {
      projectRoot: projectPath,
      webRoot: structure.web ? rel(projectPath, structure.web) : null,
      apiDir: structure.api ? rel(projectPath, structure.api) : null,
      domainDir: structure.domain ? rel(projectPath, structure.domain) : null,
      pageTsxPath: null,
      pageCandidatesTried: [],
    },
    navRoute: null,
    suggestedRoutes: [],
    charts: [],
    apiEndpoints: [],
    businessRules: [],
    entity: null,
    existingDoc: { found: false, files: [], forbiddenSections: [], missingRequired: [] },
  }

  // ── 1. Locate the real page TSX + 2. detect its charts (user type) ───────
  if (type === 'user' && structure.web) {
    // Explicit pagePath wins; otherwise auto-detect from src/pages by name.
    if (input.pagePath) {
      const abs = path.resolve(projectPath, input.pagePath)
      if (await fileExists(abs)) {
        report.resolved.pageTsxPath = rel(projectPath, abs)
        report.charts = await detectCharts(abs, structure.web, projectPath, warnings)
      } else {
        warnings.push(`Provided pagePath not found: ${input.pagePath} — falling back to auto-detection.`)
      }
    }
    if (!report.resolved.pageTsxPath) {
      const pagesDir = path.join(structure.web, 'src', 'pages')
      if (await directoryExists(pagesDir)) {
        const allPages = await findFiles('**/*.tsx', { cwd: pagesDir })
        const needles = [pluralize(pascal), pascal, singular].map((n) => n.toLowerCase())
        const matches = allPages.filter((f) => {
          const base = path.basename(f, '.tsx').toLowerCase()
          return base.endsWith('page') && needles.some((n) => base.includes(n))
        })
        report.resolved.pageCandidatesTried = matches.map((f) => rel(projectPath, f))
        // Prefer a list/template page (the module's landing UI), else shortest name.
        const preferred =
          matches.find((f) => /(list|template)page\.tsx$/i.test(path.basename(f))) ??
          [...matches].sort((a, b) => path.basename(a).length - path.basename(b).length)[0]
        if (preferred) {
          report.resolved.pageTsxPath = rel(projectPath, preferred)
          report.charts = await detectCharts(preferred, structure.web, projectPath, warnings)
        } else {
          warnings.push(
            `No page TSX found under src/pages matching "${pascal}". The Mock UI must be authored from the live app / pagespec.`,
          )
        }
      } else {
        warnings.push(`Web pages dir not found: ${rel(projectPath, pagesDir)}`)
      }
    }
  } else if (type === 'user') {
    warnings.push('Web project not detected — cannot locate the real page; Mock UI must be authored manually.')
  }

  // ── 3. API endpoints from the controller ─────────────────────────────────
  if (structure.api) {
    const controller = await findOne(structure.api, [`**/${pascal}*Controller.cs`, `**/${singular}*Controller.cs`])
    if (controller) {
      const content = await readText(controller)
      report.navRoute = extractNavRoutes(content)[0]?.fullNavRoute ?? null
      const permMap = await loadPermissionConstants(structure, warnings)
      report.apiEndpoints = extractEndpoints(content, report.navRoute, permMap)
      if (report.apiEndpoints.length === 0) {
        warnings.push(`Controller found (${rel(projectPath, controller)}) but no endpoints parsed.`)
      }
    } else {
      warnings.push(`No controller matching "${pascal}" found under ${report.resolved.apiDir}.`)
    }
  } else {
    warnings.push('API layer not detected — API endpoints not extracted.')
  }

  // ── 4. Entity props + business rules from the domain entity (+ tests) ─────
  if (structure.domain) {
    const entityFile = await findOne(structure.domain, [`**/${singular}.cs`, `**/${pascal}.cs`])
    if (entityFile) {
      const content = await readText(entityFile)
      report.entity = {
        name: path.basename(entityFile, '.cs'),
        file: rel(projectPath, entityFile),
        properties: extractEntityProperties(content),
      }
      const domainRules = extractRulesFromEntity(content)
      // Domain guard clauses are authoritative. Unit tests mostly re-encode the
      // same guards (noisy), so only fall back to tests when the entity yields none.
      const testRules = domainRules.length > 0 ? [] : await extractRulesFromTests(projectPath, singular, pascal)
      report.businessRules = formatRules(mergeRules(domainRules, testRules))
    } else {
      warnings.push(`No domain entity found for "${singular}" — business rules / entity props not extracted.`)
    }
  } else {
    warnings.push('Domain layer not detected — business rules not extracted.')
  }

  // ── 5. Suggested routes (best-effort, for the wiring step) ────────────────
  if (report.navRoute) {
    const base = report.navRoute.replace(/\./g, '/')
    report.suggestedRoutes = [base, `${base}/list`, `${base}/create`]
  } else if (application) {
    report.suggestedRoutes = [`${application}/${target}`]
  }

  // ── 6. Scan the EXISTING doc for structural compliance ────────────────────
  // Mirror checks (SKILL.md rule #12):
  //   - forbiddenSections: problème/solution, Bénéfices, Avant/Après must be ABSENT.
  //   - missingRequired:   the factual "objective" Section 1 (all types) and the
  //                        header accent tagline "summary" (user) must be PRESENT.
  // Surfaces all so generation fixes them (step-02); the step-03 gate re-runs
  // this scan and blocks until the doc is clean AND complete.
  report.existingDoc = await scanExistingDoc(structure.web, target, namespace, projectPath, type)

  return report
}

// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────

function rel(root: string, abs: string): string {
  return path.relative(root, abs).replace(/\\/g, '/')
}

// ── Forbidden-section scan (problème/solution, Bénéfices, Avant/Après) ──────

/**
 * Detect the banned "sales" framing in a single doc artifact (PURE — unit-tested).
 *
 * Signals are structural to keep false positives near zero (a FAQ *answer* that
 * merely contains the word "solution" is NOT flagged — only the structural key is):
 *   - i18n JSON  → a KEY named problem | solution | benefits | beforeAfter
 *   - doc-data.ts → an object KEY benefits | beforeAfter | problem | solution
 *   - *.tsx       → a `t('…benefits/beforeAfter/problem/solution…')` reference,
 *                   or a hardcoded marketing section title (Bénéfices / Avant / Après).
 */
export function findForbidden(content: string, relFile: string): ForbiddenSection[] {
  const out: ForbiddenSection[] = []
  const isJson = relFile.endsWith('.json')
  const isTs = /\.tsx?$/.test(relFile)
  const lines = content.split('\n')

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    // A single line can carry several forbidden keys (compact JSON / TS) — collect them all.
    const tokens = new Set<string>()

    if (isJson) {
      for (const m of line.matchAll(/"(problem|solution|benefits|beforeAfter)"\s*:/g)) tokens.add(m[1])
    } else if (isTs) {
      for (const m of line.matchAll(/(?:^|[{,\s])(benefits|beforeAfter|problem|solution)\s*:/g)) tokens.add(m[1])
      for (const m of line.matchAll(/\bt\(\s*['"][^'"]*\b(problem|solution|benefits|beforeAfter)\b/g)) tokens.add(m[1])
      if (/Bénéfices|Avant\s*\/\s*Après/.test(line)) tokens.add('literal-title')
    }

    for (const token of tokens) {
      out.push({ file: relFile, line: i + 1, token, snippet: line.trim().slice(0, 120) })
    }
  }
  return out
}

/**
 * Detect the mandatory `objective` section in a single doc artifact (PURE —
 * unit-tested). The mirror of `findForbidden`: the doc must LEAD with a factual
 * objective (the Section 1 that replaced problème/solution).
 *
 * Structural signals (same low-false-positive philosophy):
 *   - i18n JSON  → an `"objective"` KEY (top-level for `user`, or `sections.objective`
 *                  / `overview.objective` for DocRenderer types).
 *   - doc-data.ts → an `objective:` object KEY (e.g. `overview: { objective: … }`).
 *   - *.tsx       → a `t('…objective…')` reference rendering the section.
 *
 * Tolerates the French key spelling `objectif` so a doc that HAS the section but
 * keyed it `objectif` is not falsely reported missing (the gate asserts the
 * section exists, not the exact key spelling — instructions push `objective`).
 */
export function hasObjective(content: string, relFile: string): boolean {
  if (relFile.endsWith('.json')) return /"objecti(?:ve|f)"\s*:/.test(content)
  if (/\.tsx?$/.test(relFile)) {
    return /(?:^|[{,\s])objecti(?:ve|f)\s*:/.test(content) || /\bt\(\s*['"][^'"]*\bobjecti(?:ve|f)\b/.test(content)
  }
  return false
}

/**
 * Detect the header **accent tagline** in a `user` doc (PURE — unit-tested): the
 * one-line accent summary between the title and the subtitle. Its absence makes
 * the header inconsistent (some pages show it, some don't — what the user flagged).
 *
 * Canonical i18n key is `summary`; `valueProposition` is the legacy alias and is
 * still accepted so a doc using it is not falsely flagged. Signals:
 *   - i18n JSON → a `"summary"` / `"valueProposition"` KEY.
 *   - *.tsx     → a `t('summary')` / `t('valueProposition')` reference.
 */
export function hasSummary(content: string, relFile: string): boolean {
  if (relFile.endsWith('.json')) return /"(?:summary|valueProposition)"\s*:/.test(content)
  if (/\.tsx?$/.test(relFile)) return /\bt\(\s*['"][^'"]*\b(?:summary|valueProposition)\b/.test(content)
  return false
}

/**
 * Detect overflow-prone technical tokens in a doc page (PURE — unit-tested):
 * every `<code>` element and every `font-mono` element must carry a break
 * utility (`break-all` / `break-words` / `overflow-wrap`). Without it a long
 * unbreakable token (URL, permission key, namespace) fixes a wide min-content
 * on its grid/flex cell and overflows the card — the PERMISSION / URL
 * regression the gate blocks.
 *
 * Only the page TSX carries layout, so non-`.tsx` sources (i18n JSON,
 * doc-data.ts) are never scanned. A `<code className="font-mono">` reports a
 * single `code-no-break` finding (code precedence — no double report).
 */
export function findOverflowRisks(content: string, relFile: string): OverflowRisk[] {
  if (!relFile.endsWith('.tsx')) return []

  const out: OverflowRisk[] = []
  const lines = content.split('\n')
  const tagRe = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g
  const breakRe = /break-all|break-words|overflow-wrap/

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    for (const m of line.matchAll(tagRe)) {
      const tag = m[0]
      const name = m[1]
      const cls = tag.match(/className=(?:"([^"]*)"|\{`([^`]*)`\})/)
      const className = cls ? (cls[1] ?? cls[2] ?? '') : ''
      if (breakRe.test(className)) continue
      if (name === 'code') {
        out.push({ file: relFile, line: i + 1, token: 'code-no-break', snippet: line.trim().slice(0, 120) })
      } else if (/\bfont-mono\b/.test(className)) {
        out.push({ file: relFile, line: i + 1, token: 'mono-no-break', snippet: line.trim().slice(0, 120) })
      }
    }
  }
  return out
}

/**
 * Locate the EXISTING doc artifacts for a module (the generated doc page +
 * doc-data.ts + the central i18n files) and scan each for structural compliance:
 * forbidden "sales" sections (must be absent) AND the mandatory objective (must
 * be present). Read-only and null-safe: a not-yet-documented module yields
 * `found: false` with empty findings (nothing to require before it exists).
 */
async function scanExistingDoc(
  web: string | undefined,
  target: string,
  namespace: string,
  projectRoot: string,
  type: string,
): Promise<ExistingDocReport> {
  if (!web) return { found: false, files: [], forbiddenSections: [], missingRequired: [] }

  const kebab = toKebabCase(namespace) // docsSupportSla → docs-support-sla
  const patterns = [
    `**/pages/docs/business/**/${target}/index.tsx`, // user + DocRenderer page
    `**/pages/docs/business/**/${target}/doc-data.ts`, // DocRenderer data
    `**/i18n/locales/*/${kebab}.json`, // central i18n (all langs)
  ]

  const absFiles: string[] = []
  for (const pattern of patterns) {
    for (const f of await findFiles(pattern, { cwd: web })) {
      if (!absFiles.includes(f)) absFiles.push(f)
    }
  }

  const forbiddenSections: ForbiddenSection[] = []
  const files: string[] = []
  let objectivePresent = false
  let summaryPresent = false
  for (const abs of absFiles) {
    const relFile = rel(projectRoot, abs)
    const content = await readText(abs)
    // Scope page/data candidates to THIS doc by namespace. The page/data globs
    // match by folder name only (`target`), so a name shared across apps (e.g.
    // myspace/tenants AND administration/tenants) would mis-attribute another
    // module's findings. A page references its namespace (`useTranslation('<ns>')`),
    // a doc-data its key prefix (`'<ns>.overview…'`). The i18n `.json` files are
    // already filename-scoped (`docs-<kebab>.json`) and carry no namespace key, so
    // they are never filtered.
    if (!relFile.endsWith('.json') && !content.includes(namespace)) continue
    files.push(relFile)
    forbiddenSections.push(...findForbidden(content, relFile))
    if (hasObjective(content, relFile)) objectivePresent = true
    if (hasSummary(content, relFile)) summaryPresent = true
  }

  // Only require sections once a doc actually exists (post-generation gate),
  // never before it is authored.
  const found = files.length > 0
  const missingRequired: string[] = []
  if (found && !objectivePresent) missingRequired.push('objective')
  // The accent header tagline is a `user`-page element (DocRenderer types render
  // their own header), so only require `summary` there.
  if (found && type === 'user' && !summaryPresent) missingRequired.push('summary')

  return { found, files, forbiddenSections, missingRequired }
}

/** First file matching any of the patterns under baseDir (shortest basename wins). */
async function findOne(baseDir: string, patterns: string[]): Promise<string | null> {
  for (const p of patterns) {
    const files = await findFiles(p, { cwd: baseDir })
    if (files.length > 0) {
      return [...files].sort((a, b) => path.basename(a).length - path.basename(b).length)[0]
    }
  }
  return null
}

/** Resolve a `@/...` import to a real source file under {webRoot}/src/. */
async function resolveAlias(importPath: string, webRoot: string): Promise<string | null> {
  const relPath = importPath.replace(/^@\//, 'src/')
  const baseNoExt = path.join(webRoot, relPath)
  for (const cand of [`${baseNoExt}.tsx`, `${baseNoExt}.ts`, path.join(baseNoExt, 'index.tsx')]) {
    if (await fileExists(cand)) return cand
  }
  return null
}

function mockPatternFor(rechartsType: string | null, layout: string | null): string {
  if (!rechartsType) return 'unknown chart — open the source component and match its recharts type'
  if (rechartsType === 'BarChart') {
    return layout === 'vertical'
      ? 'horizontal bar list (div width:%) — Recharts layout="vertical"'
      : 'vertical histogram (flex items-end, bars height:%)'
  }
  return RECHARTS_MOCK[rechartsType] ?? 'see templates.md → recharts → Mock UI mapping'
}

/** Find chart components imported from @/components/{dashboard,charts} and classify each. */
async function detectCharts(
  pageFile: string,
  webRoot: string,
  projectRoot: string,
  warnings: string[],
): Promise<ChartInfo[]> {
  const content = await readText(pageFile)
  const charts: ChartInfo[] = []
  const rechartsRe = /<(BarChart|PieChart|LineChart|AreaChart|RadialBarChart|Doughnut|Pie)\b/
  const layoutRe = /layout\s*=\s*["'](vertical|horizontal)["']/

  // (a) Chart components imported from @/components/{dashboard,charts}.
  const importRe =
    /import\s+(?:\{([^}]*)\}|(\w+))\s+from\s+['"](@\/components\/(?:dashboard|charts)\/[^'"]+)['"]/g
  for (const m of content.matchAll(importRe)) {
    const named = (m[1] ?? '')
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean)
    const components = named.length > 0 ? named : m[2] ? [m[2]] : []
    const importPath = m[3]
    const sourceFile = await resolveAlias(importPath, webRoot)
    let rechartsType: string | null = null
    let layout: string | null = null
    if (sourceFile) {
      const src = await readText(sourceFile)
      rechartsType = src.match(rechartsRe)?.[1] ?? null
      layout = src.match(layoutRe)?.[1] ?? null
    }
    for (const component of components) {
      // Skip non-chart dashboard helpers (KpiCard, StatCard…): no recharts AND
      // not chart-named. They are not graphics that need a Mock UI pattern.
      if (!rechartsType && !/chart|graph/i.test(component)) continue
      if (!sourceFile) warnings.push(`Chart source not resolved for import "${importPath}" — inspect it manually.`)
      charts.push({
        component,
        importPath,
        sourceFile: sourceFile ? rel(projectRoot, sourceFile) : null,
        rechartsType,
        layout,
        mockUiPattern: mockPatternFor(rechartsType, layout),
      })
    }
  }

  // (b) recharts used INLINE in the page itself (no wrapper component).
  if (/from\s+['"]recharts['"]/.test(content)) {
    const inlineType = content.match(rechartsRe)?.[1] ?? null
    if (inlineType) {
      const layout = content.match(layoutRe)?.[1] ?? null
      charts.push({
        component: '(inline recharts)',
        importPath: 'recharts',
        sourceFile: null,
        rechartsType: inlineType,
        layout,
        mockUiPattern: mockPatternFor(inlineType, layout),
      })
    }
  }

  return charts
}

// ── API endpoints (ported from scripts/extract-api-endpoints.ts) ──────────

async function loadPermissionConstants(
  structure: SmartStackStructure,
  warnings: string[],
): Promise<Map<string, string>> {
  const map = new Map<string, string>()
  const dirs = [structure.application, structure.api, structure.apiCore, structure.domain].filter(
    (d): d is string => Boolean(d),
  )
  let permFile: string | null = null
  for (const dir of dirs) {
    const files = await findFiles('**/Permissions.cs', { cwd: dir })
    if (files.length > 0) {
      permFile = files[0]
      break
    }
  }
  if (!permFile) {
    warnings.push('Permissions.cs not found — permissions resolved by name inference.')
    return map
  }
  const content = await readText(permFile)
  const lines = content.split('\n')
  let ns = ''
  for (const line of lines) {
    const classMatch = line.match(/public\s+(?:static\s+)?(?:partial\s+)?class\s+(\w+)/)
    if (classMatch) {
      ns = ns ? `${ns}.${classMatch[1]}` : `Permissions.${classMatch[1]}`
    }
    const constMatch = line.match(/public\s+const\s+string\s+(\w+)\s*=\s*"([^"]+)"/)
    if (constMatch && ns) {
      map.set(`${ns}.${constMatch[1]}`, constMatch[2])
    }
    if (line.trim() === '}' && ns) {
      const lastDot = ns.lastIndexOf('.')
      ns = lastDot > 0 ? ns.substring(0, lastDot) : ''
    }
  }
  return map
}

function resolvePermission(constantPath: string, map: Map<string, string>, navRoute: string): string {
  if (map.has(constantPath)) return map.get(constantPath)!
  const parts = constantPath.split('.')
  if (parts.length >= 3) {
    const action = parts[parts.length - 1].toLowerCase()
    const actionMap: Record<string, string> = {
      view: 'read',
      read: 'read',
      create: 'create',
      update: 'update',
      delete: 'delete',
      assign: 'assign',
      execute: 'execute',
      export: 'export',
      import: 'import',
    }
    return `${navRoute}.${actionMap[action] ?? action}`
  }
  return `${navRoute}.unknown`
}

function extractEndpoints(content: string, navRoute: string | null, permMap: Map<string, string>): ApiEndpoint[] {
  const lines = content.split('\n')
  const endpoints: ApiEndpoint[] = []
  const basePath = navRoute ? `/api/${navRoute.replace(/\./g, '/')}` : '/api'
  const httpMethods = ['Get', 'Post', 'Put', 'Delete', 'Patch'] as const

  let curMethod: { method: string; suffix: string } | null = null
  let curPermission: string | null = null

  for (const line of lines) {
    for (const method of httpMethods) {
      const match = line.match(new RegExp(`\\[Http${method}(?:\\("([^"]+)"\\))?\\]`))
      if (match) {
        curMethod = { method: method.toUpperCase(), suffix: match[1] ? `/${match[1]}` : '' }
        break
      }
    }
    const permMatch = line.match(/\[RequirePermission\(([^)]+)\)\]/)
    if (permMatch) {
      const arg = permMatch[1].includes(',') ? permMatch[1].split(',')[0].trim() : permMatch[1].trim()
      curPermission = resolvePermission(arg, permMap, navRoute ?? '')
      continue
    }
    if (curMethod) {
      const handler = line.match(/public\s+(?:async\s+)?Task.*?\s+(\w+)\s*\(/)?.[1]
      if (handler) {
        endpoints.push({
          method: curMethod.method,
          path: basePath + curMethod.suffix,
          handler,
          permission: curPermission ?? (navRoute ? `${navRoute}.unknown` : ''),
        })
        curMethod = null
        curPermission = null
      }
    }
  }
  return endpoints
}

// ── Entity properties ──────────────────────────────────────────────────────

function extractEntityProperties(content: string): EntityProperty[] {
  const props: EntityProperty[] = []
  const re = /public\s+([A-Za-z0-9_<>[\]?]+)\s+(\w+)\s*\{\s*get/g
  const skip = /(^id$|id$|hash|token|normalized|password|salt|concurrencystamp|securitystamp)/i
  const seen = new Set<string>()
  for (const m of content.matchAll(re)) {
    const name = m[2]
    if (skip.test(name) || seen.has(name)) continue
    seen.add(name)
    const rawType = m[1]
    props.push({ name, type: rawType.replace(/\?$/, ''), nullable: rawType.endsWith('?') })
  }
  return props
}

// ── Business rules (ported from scripts/extract-business-rules.ts) ─────────

interface RawRule {
  source: 'domain' | 'test'
  exception: string
  context: string
  category: string
}

function extractRulesFromEntity(content: string): RawRule[] {
  const lines = content.split('\n')
  const rules: RawRule[] = []
  // Dedupe by exception text: the same guard ("Email is required") recurs across
  // Create/Update/… methods, and a one-line `if (…) throw` matches on both the
  // `if` line and the `throw` line — both would otherwise double-count.
  const seen = new Set<string>()
  let curMethod = ''
  const add = (exception: string, context: string): void => {
    const key = exception.toLowerCase()
    if (seen.has(key)) return
    seen.add(key)
    rules.push({ source: 'domain', exception, context, category: inferCategory(exception, context) })
  }
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    const methodMatch = line.match(/public\s+(?:static\s+)?(?:[\w<>?[\]]+)\s+(\w+)\s*\(/)
    if (methodMatch) curMethod = methodMatch[1]

    const directThrow = line.match(/throw\s+new\s+(?:\w+\.)?DomainException\s*\(\s*"([^"]+)"/)
    if (directThrow) {
      add(directThrow[1], curMethod)
      continue
    }
    const guard = line.match(/if\s*\((.+)\)/)
    if (guard && i + 1 < lines.length) {
      const next = lines[i + 1].trim().match(/throw\s+new\s+(?:\w+\.)?DomainException\s*\(\s*"([^"]+)"/)
      if (next) add(next[1], `${curMethod} (${guard[1].trim()})`)
    }
  }
  return rules
}

async function extractRulesFromTests(projectPath: string, singular: string, pascal: string): Promise<RawRule[]> {
  const testFile =
    (await findOne(projectPath, [`**/${singular}Tests.cs`, `**/${pascal}Tests.cs`])) ?? null
  if (!testFile) return []
  const content = await readText(testFile)
  const lines = content.split('\n')
  const rules: RawRule[] = []
  let curTest = ''
  for (const line of lines) {
    const testMatch = line.match(/public\s+(?:async\s+)?(?:void|Task)\s+(\w+)\s*\(/)
    if (testMatch) curTest = testMatch[1]
    const assert = line.match(/Should\(\)\.Throw<DomainException>\(\)(?:\.WithMessage\("([^"]+)"\))?/)
    if (assert && curTest) {
      rules.push({
        source: 'test',
        exception: exceptionFromTestName(curTest, assert[1] ?? ''),
        context: curTest,
        category: inferCategory(curTest, curTest),
      })
    }
  }
  return rules
}

function exceptionFromTestName(testName: string, hint: string): string {
  const parts = testName.split('_')
  if (parts.length >= 3) {
    const cond = parts[1].replace(/^With/, '')
    if (cond.includes('Empty')) return `${capitalize(cond.replace('Empty', '').toLowerCase())} is required`
    if (cond.includes('Invalid')) return `Invalid ${cond.replace('Invalid', '').toLowerCase()}`
    if (cond.includes('Duplicate')) return `${capitalize(cond.replace('Duplicate', '').toLowerCase())} must be unique`
  }
  return hint.replace(/\*/g, '') || testName
}

function inferCategory(text: string, context: string): string {
  const l = text.toLowerCase()
  if (l.includes('required') || l.includes('empty') || l.includes('null')) return 'Validation'
  if (l.includes('unique') || l.includes('duplicate') || l.includes('exist')) return 'Validation'
  if (l.includes('password') || l.includes('token') || l.includes('auth')) return 'Securite'
  if (l.includes('permission') || l.includes('role') || l.includes('access')) return 'Autorisation'
  if (l.includes('tenant') || l.includes('isolation')) return 'Multi-tenant'
  if (l.includes('email') || l.includes('notification')) return 'Communication'
  if (l.includes('audit') || l.includes('log') || l.includes('track')) return 'Audit'
  if (/create|update|delete/i.test(context)) return 'Logique metier'
  return 'Autre'
}

function mergeRules(domainRules: RawRule[], testRules: RawRule[]): RawRule[] {
  const merged = [...domainRules]
  const seen = new Set(domainRules.map((r) => r.exception.toLowerCase()))
  for (const r of testRules) {
    if (!seen.has(r.exception.toLowerCase())) {
      merged.push(r)
      seen.add(r.exception.toLowerCase())
    }
  }
  return merged
}

function formatRules(rules: RawRule[]): BusinessRule[] {
  return rules.map((rule, i) => ({
    id: `BR-${String(i + 1).padStart(3, '0')}`,
    name: ruleName(rule.exception),
    category: rule.category || 'Autre',
    statement: rule.exception,
  }))
}

function ruleName(exception: string): string {
  const l = exception.toLowerCase()
  const reqIdx = l.indexOf(' is required')
  if (reqIdx > 0) return `${exception.slice(0, reqIdx)} requis`
  const uniqueIdx = l.indexOf(' must be unique')
  if (uniqueIdx > 0) return `Unicite ${exception.slice(0, uniqueIdx).toLowerCase()}`
  if (l.includes('password') && (l.includes('least') || l.includes('strong'))) return 'Mot de passe fort'
  if (l.includes('email') && l.includes('valid')) return 'Email valide'
  const words = exception.split(' ').slice(0, 5).join(' ')
  return words.length > 50 ? `${words.substring(0, 47)}...` : words
}
