/**
 * audit-dev-api / audit.ts
 *
 * MVP scope: implements DEV-API-010 (pagespec → controller match) and
 * DEV-API-011 (reverse: controller → pagespec backing) — the 2 rules that
 * NEED deterministic execution because `ba-develop` Phase 2 calls this CLI
 * as its gate. The other DEV-API-001..009 rules remain conversational
 * (Claude reads / greps via the skill SKILL.md verbatim).
 *
 * TODO[refactor]: extract `loadPageActions` and `loadControllerEndpoints`
 * to `lib/audit-action-loaders.ts` and import them from there + from
 * `audit-dev-actions-alignment/audit.ts`. For now they are duplicated to
 * avoid introducing a regression in the working alignment CLI.
 */
import path from 'node:path'
import { findFiles, readText } from '../../../../lib/fs.js'
import {
  STANDARD_CRUD_CODES,
  defaultEndpoint,
  expectedControllerRoute,
} from '../../../../lib/page-spec-actions.js'
import type {
  AuditDevApiArgs,
  AuditReport,
  ControllerEndpointEntry,
  Finding,
  PageActionEntry,
  PageScreenEntry,
  ScreenTodoEntry,
} from './types.js'

// ─── Public entry ─────────────────────────────────────────────────────────

export async function audit(args: AuditDevApiArgs): Promise<AuditReport> {
  const pageActions = await loadPageActions(path.resolve(args.modulePath))
  const pageScreens = await loadPageScreens(path.resolve(args.modulePath))
  const controllerEndpoints = await loadControllerEndpoints(path.resolve(args.backendPath))
  const screenTodos = await loadScreenTodos(path.resolve(args.backendPath))

  const ruleFilter = new Set(args.rules)
  const runAll = ruleFilter.size === 0
  const findings: Finding[] = []

  if (runAll || ruleFilter.has('DEV-API-010')) {
    findings.push(...checkDevApi010(pageActions, controllerEndpoints))
  }
  if (runAll || ruleFilter.has('DEV-API-011')) {
    findings.push(...checkDevApi011(pageActions, controllerEndpoints))
  }
  if (runAll || ruleFilter.has('DEV-API-012')) {
    findings.push(...checkDevApi012(pageScreens, controllerEndpoints))
  }
  if (runAll || ruleFilter.has('DEV-API-013')) {
    findings.push(...checkDevApi013(screenTodos))
  }
  if (runAll || ruleFilter.has('DEV-API-014')) {
    findings.push(...checkDevApi014(controllerEndpoints))
  }

  const counts = { errors: 0, warnings: 0, oks: 0 }
  const byRule: Record<string, number> = {}
  for (const f of findings) {
    if (f.severity === 'err') counts.errors++
    else if (f.severity === 'warn') counts.warnings++
    else counts.oks++
    byRule[f.code] = (byRule[f.code] ?? 0) + 1
  }

  const markdown = renderMarkdown({ counts, findings, args })

  return {
    scope: 'devApi',
    applicationCode: args.appCode,
    moduleCode: args.moduleCode,
    modulePath: args.modulePath,
    backendPath: args.backendPath,
    filesInspected: pageActions.length + controllerEndpoints.length,
    findings,
    counts,
    byRule,
    markdown,
  }
}

// ─── DEV-API-010 ──────────────────────────────────────────────────────────
// Every pagespec.actions[].kind="api" lands on the controller with the
// expected [HttpVerb("<route>")] attribute.

function checkDevApi010(
  pageActions: PageActionEntry[],
  controllerEndpoints: ControllerEndpointEntry[],
): Finding[] {
  // De-dup pagespec entries by (entity, httpMethod, scope, endpoint) — the same
  // action may appear on multiple views and is expected to land ONCE on the
  // controller.
  const seen = new Set<string>()
  const unique: PageActionEntry[] = []
  for (const a of pageActions) {
    const key = `${a.entity}|${a.httpMethod}|${a.scope}|${a.endpoint}`
    if (seen.has(key)) continue
    seen.add(key)
    unique.push(a)
  }

  const missing: Array<{ entity: string; verb: string; route: string; pagespec: string }> = []
  const matched: PageActionEntry[] = []

  for (const a of unique) {
    const expected = expectedControllerRoute(a.scope, a.endpoint)
    const expectedNorm = normaliseRoute(expected)
    const verbLower = a.httpMethod.toLowerCase()
    const match = controllerEndpoints.find(
      (c) =>
        c.entity === a.entity &&
        c.verb === verbLower &&
        normaliseRoute(c.routePath) === expectedNorm,
    )
    if (match) {
      matched.push(a)
    } else {
      missing.push({
        entity: a.entity,
        verb: a.httpMethod,
        route: expected,
        pagespec: a.file,
      })
    }
  }

  if (missing.length === 0) {
    return [
      {
        dimension: 'devApi',
        code: 'DEV-API-010',
        severity: 'ok',
        label: 'DEV_API_010_ok',
        params: { count: matched.length },
        solution: '',
      },
    ]
  }

  return [
    {
      dimension: 'devApi',
      code: 'DEV-API-010',
      severity: 'err',
      label: 'DEV_API_010_err',
      params: {
        actions: missing.map((m) => `${m.entity}:${m.verb}:${m.route}`).join(','),
        details: missing,
      },
      solution:
        'Re-run Phase 2 of /ba-develop after refreshing the pagespecs. The orchestrator must derive customActions[] from pagespec.actions[] and forward them to scaffold-controller. The endpoint field of the pagespec is compiled verbatim into [HttpVerb("<endpoint>")].',
      fixSkill: 'backend-controller',
      fixPhaseKey: 'api',
    },
  ]
}

// ─── DEV-API-011 ──────────────────────────────────────────────────────────
// Reciprocal of DEV-API-010: no non-CRUD controller endpoint that lacks a
// pagespec backing.

function checkDevApi011(
  pageActions: PageActionEntry[],
  controllerEndpoints: ControllerEndpointEntry[],
): Finding[] {
  // Build a set of (entity, verb, route) tuples expected by the pagespec.
  const expectedSet = new Set<string>()
  for (const a of pageActions) {
    const route = normaliseRoute(expectedControllerRoute(a.scope, a.endpoint))
    expectedSet.add(`${a.entity}|${a.httpMethod.toLowerCase()}|${route}`)
  }
  // Also include the CRUD-default endpoints `kebab(code)` so a pagespec that
  // omits the explicit `endpoint` still covers them.
  for (const a of pageActions) {
    const def = defaultEndpoint(a.code)
    const route = normaliseRoute(expectedControllerRoute(a.scope, def))
    expectedSet.add(`${a.entity}|${a.httpMethod.toLowerCase()}|${route}`)
  }

  const phantom: Array<{ entity: string; verb: string; route: string; file: string }> = []
  for (const c of controllerEndpoints) {
    const route = normaliseRoute(c.routePath)
    if (isCrudShape(route)) continue
    const key = `${c.entity}|${c.verb}|${route}`
    if (expectedSet.has(key)) continue
    phantom.push({ entity: c.entity, verb: c.verb.toUpperCase(), route, file: c.file })
  }

  if (phantom.length === 0) {
    return [
      {
        dimension: 'devApi',
        code: 'DEV-API-011',
        severity: 'ok',
        label: 'DEV_API_011_ok',
        params: {},
        solution: '',
      },
    ]
  }

  return [
    {
      dimension: 'devApi',
      code: 'DEV-API-011',
      severity: 'warn',
      label: 'DEV_API_011_warn',
      params: {
        endpoints: phantom.map((p) => `${p.entity}:${p.verb}:${p.route}`).join(','),
        details: phantom,
      },
      solution:
        'Either declare the missing custom action in the corresponding screen.md (then re-run /ba-create-prd) or delete the orphan endpoint. Phantom endpoints are unreachable from the UI under the per-page contract.',
    },
  ]
}

// ─── Loaders (duplicated from audit-dev-actions-alignment — see TODO above) ─

const PAGESPEC_FILENAME_RE = /([A-Z][A-Za-z0-9]*)\.([a-z][a-z-]*)\.md$/

async function loadPageActions(modulePath: string): Promise<PageActionEntry[]> {
  const out: PageActionEntry[] = []
  const files = await findFiles('pagespecs/*.md', { cwd: modulePath })
  for (const abs of files) {
    const filename = path.basename(abs)
    const m = PAGESPEC_FILENAME_RE.exec(filename)
    if (!m) continue
    const entity = m[1]
    const source = await readText(abs)
    out.push(...parsePagespecActions(source, entity, filename))
  }
  return out
}

interface PageSpecShape {
  module?: string
  section?: string
  actions?: unknown[]
}

interface RawAction {
  code: string
  kind?: string
  scope?: string
  endpoint?: string
  httpMethod?: string
  ucReference?: string
}

function isPageSpec(parsed: unknown): parsed is PageSpecShape {
  return typeof parsed === 'object' && parsed !== null
}

function isAction(action: unknown): action is RawAction {
  return typeof action === 'object' && action !== null && typeof (action as RawAction).code === 'string'
}

function isValidScope(scope: unknown): scope is PageActionEntry['scope'] {
  return scope === 'row' || scope === 'bulk' || scope === 'header'
}

function parsePagespecActions(source: string, entity: string, filename: string): PageActionEntry[] {
  const blocks: string[] = []
  const re = /```json\s*\n([\s\S]*?)\n```/g
  let m: RegExpExecArray | null
  while ((m = re.exec(source)) !== null) blocks.push(m[1])

  const entries: PageActionEntry[] = []
  for (const block of blocks) {
    let parsed: unknown
    try {
      parsed = JSON.parse(block)
    } catch {
      continue
    }
    if (!isPageSpec(parsed)) continue
    const ps = parsed as PageSpecShape
    const actions = Array.isArray(ps.actions) ? ps.actions : []
    for (const action of actions) {
      if (!isAction(action)) continue
      const kind = action.kind ?? 'api'
      if (kind !== 'api') continue
      if (STANDARD_CRUD_CODES.has(action.code)) continue
      const endpoint = action.endpoint ?? defaultEndpoint(action.code)
      const httpMethod = (action.httpMethod ?? 'POST').toUpperCase() as PageActionEntry['httpMethod']
      const scope = action.scope
      if (!isValidScope(scope)) continue
      entries.push({
        file: filename,
        entity,
        module: (ps.module ?? '').toLowerCase(),
        section: (ps.section ?? '').toLowerCase(),
        code: action.code,
        endpoint,
        httpMethod,
        scope,
        ucReference: action.ucReference,
      })
    }
  }
  return entries
}

const C_HTTP_VERBS = ['Get', 'Post', 'Put', 'Delete', 'Patch']
const CONTROLLER_FILE_RE = /^([A-Z][A-Za-z0-9]*)Controller\.cs$/
const SCREEN_CONTROLLER_FILE_RE = /^([A-Z][A-Za-z0-9]*)ScreenController\.cs$/
const CONTROLLER_SERVICE_FIELD_RE = /\bI([A-Z][A-Za-z0-9]*)Service\b/
const CLASS_ROUTE_RE = /\[Route\(\s*"([^"]+)"\s*\)\]/

async function loadControllerEndpoints(backendPath: string): Promise<ControllerEndpointEntry[]> {
  const out: ControllerEndpointEntry[] = []
  const files = await findFiles('**/*Controller.cs', { cwd: backendPath })
  for (const abs of files) {
    const filename = path.basename(abs)
    const screenMatch = SCREEN_CONTROLLER_FILE_RE.exec(filename)
    const fileMatch = CONTROLLER_FILE_RE.exec(filename)
    if (!fileMatch && !screenMatch) continue
    const source = await readText(abs)
    if (!source) continue
    const serviceMatch = CONTROLLER_SERVICE_FIELD_RE.exec(source)
    // Entity derivation differs by stratum:
    //   - Screen controllers: filename ALWAYS wins. The Plural in the
    //     filename ({EntityPlural}ScreenController) is the canonical entity
    //     anchor — never the injected service (which may be a fork like
    //     ITypeAffaireScreenService, exactly the divergence DEV-API-014 catches).
    //   - Integration controllers: prefer the injected I{Entity}Service (closer
    //     to the truth than the controller plural form).
    let entity: string
    if (screenMatch) {
      entity = singularize(screenMatch[1]) // "TypeAffaires" → "TypeAffaire"
    } else {
      entity = serviceMatch ? serviceMatch[1] : singularize(fileMatch![1])
    }
    const injectedService = serviceMatch ? `I${serviceMatch[1]}Service` : null
    const rel = path.relative(backendPath, abs).replace(/\\/g, '/')
    // Stratum detection: filename matches ScreenController OR path contains /Screens/.
    const stratum: 'integration' | 'screens' = (screenMatch || /\/Screens\//.test(rel)) ? 'screens' : 'integration'
    const classRouteMatch = CLASS_ROUTE_RE.exec(source)
    const classRoute = classRouteMatch ? normaliseControllerRoute(classRouteMatch[1]) : ''
    const methodRe = new RegExp(
      `\\[Http(${C_HTTP_VERBS.join('|')})(?:\\(\\s*['"]([^'"]*)['"]\\s*\\))?\\s*\\]`,
      'g',
    )
    let m: RegExpExecArray | null
    while ((m = methodRe.exec(source)) !== null) {
      const verb = m[1].toLowerCase()
      const inlineRoute = (m[2] ?? '').trim()
      const routePath = normaliseControllerRoute(inlineRoute)
      out.push({ verb, routePath, file: rel, entity, stratum, classRoute, injectedService })
    }
  }
  return out
}

// ─── Pagespec screen loader (DEV-API-012) ────────────────────────────────

const PAGESPEC_SCREEN_FILENAME_RE = /([A-Z][A-Za-z0-9]*)\.([a-z][a-z-]*)\.md$/

async function loadPageScreens(modulePath: string): Promise<PageScreenEntry[]> {
  const out: PageScreenEntry[] = []
  const files = await findFiles('pagespecs/*.md', { cwd: modulePath })
  for (const abs of files) {
    const filename = path.basename(abs)
    const m = PAGESPEC_SCREEN_FILENAME_RE.exec(filename)
    if (!m) continue
    const source = await readText(abs)
    if (!source) continue
    const blockRe = /```json\s*\n([\s\S]*?)\n```/g
    let bm: RegExpExecArray | null
    while ((bm = blockRe.exec(source)) !== null) {
      let parsed: unknown
      try {
        parsed = JSON.parse(bm[1])
      } catch {
        continue
      }
      if (typeof parsed !== 'object' || parsed === null) continue
      const ps = parsed as Record<string, unknown>
      const entity = typeof ps.entity === 'string' ? ps.entity : m[1]
      const view = typeof ps.view === 'string' ? ps.view : m[2]
      const screenCode = typeof ps.screenCode === 'string' ? ps.screenCode : ''
      if (!isValidView(view) || !screenCode) continue
      out.push({
        file: filename,
        entity,
        module: typeof ps.module === 'string' ? ps.module.toLowerCase() : '',
        section: typeof ps.section === 'string' ? ps.section.toLowerCase() : '',
        view,
        screenCode,
      })
    }
  }
  return out
}

function isValidView(v: string): v is PageScreenEntry['view'] {
  return ['list', 'detail', 'form', 'dashboard', 'kanban', 'card', 'app-home', 'module-home', 'section-home'].includes(v)
}

// ─── Screen TODO loader (DEV-API-013) ────────────────────────────────────

const SCREEN_TODO_RE = /\/\/\s*TODO\[SCREEN-[^\]]+\]/

async function loadScreenTodos(backendPath: string): Promise<ScreenTodoEntry[]> {
  const out: ScreenTodoEntry[] = []
  const files = await findFiles('**/Screens/**/*.cs', { cwd: backendPath })
  for (const abs of files) {
    const source = await readText(abs)
    if (!source) continue
    const rel = path.relative(backendPath, abs).replace(/\\/g, '/')
    const lines = source.split(/\r?\n/)
    for (let i = 0; i < lines.length; i++) {
      const m = SCREEN_TODO_RE.exec(lines[i])
      if (m) out.push({ file: rel, line: i + 1, marker: m[0].trim() })
    }
  }
  return out
}

// ─── DEV-API-012 ──────────────────────────────────────────────────────────
// Every pagespec with a list/detail/form view has a matching endpoint on the
// {EntityPlural}ScreenController. The expected endpoints per view are:
//   list   → GET  list
//   detail → GET  detail/{id}
//   form   → POST form  AND  PUT form/{id}
// Dashboard / kanban / card / home views are skipped (MVP scope of the scaffolder).

function checkDevApi012(
  screens: PageScreenEntry[],
  endpoints: ControllerEndpointEntry[],
): Finding[] {
  // Index endpoints by (stratum='screens', entity, verb, routePath).
  const screenEndpoints = endpoints.filter(e => e.stratum === 'screens')

  // Transition mode: if NO screen controller exists anywhere in the project,
  // Phase 2b has not been executed yet — emit ok with a note instead of
  // flooding the report with one err per pagespec.
  if (screenEndpoints.length === 0) {
    return [{
      dimension: 'devApi',
      code: 'DEV-API-012',
      severity: 'ok',
      label: 'DEV_API_012_ok',
      params: { count: 0, note: 'no screen-driven controllers detected — Phase 2b not yet executed' },
      solution: '',
    }]
  }

  const idx = new Set<string>()
  // Per-entity gate: only check pagespecs whose entity already has at least one
  // screen controller. Entities without any screen controller are treated as
  // not-yet-migrated (rather than reported as broken).
  const entitiesWithScreenController = new Set(screenEndpoints.map(e => e.entity))
  for (const e of screenEndpoints) {
    idx.add(`${e.entity}|${e.verb}|${normaliseRoute(e.routePath)}`)
  }

  const missing: Array<{ screenCode: string; entity: string; view: string; expected: string; pagespec: string }> = []
  let matched = 0
  for (const s of screens) {
    if (s.view !== 'list' && s.view !== 'detail' && s.view !== 'form') continue
    if (!entitiesWithScreenController.has(s.entity)) continue // not-yet-migrated entity
    const expected = expectedScreenEndpoints(s.view, s.entity)
    let allFound = true
    for (const exp of expected) {
      if (!idx.has(`${s.entity}|${exp.verb}|${normaliseRoute(exp.routePath)}`)) {
        missing.push({ screenCode: s.screenCode, entity: s.entity, view: s.view, expected: `${exp.verb.toUpperCase()} ${exp.routePath}`, pagespec: s.file })
        allFound = false
      }
    }
    if (allFound) matched++
  }

  if (missing.length === 0) {
    return [{
      dimension: 'devApi',
      code: 'DEV-API-012',
      severity: 'ok',
      label: 'DEV_API_012_ok',
      params: { count: matched },
      solution: '',
    }]
  }
  return [{
    dimension: 'devApi',
    code: 'DEV-API-012',
    severity: 'err',
    label: 'DEV_API_012_err',
    params: {
      screens: missing.map(m => `${m.screenCode}:${m.expected}`).join(','),
      details: missing,
    },
    solution: 'Re-run Phase 2b of /ba-develop for each (section, entity) missing an endpoint. The scaffold-screen-controller CLI must emit list (GET list), detail (GET detail/{id:guid}), or form (POST form + PUT form/{id:guid}) on the {EntityPlural}ScreenController per pagespec.view.',
    fixSkill: 'backend-screen-controller',
    fixPhaseKey: 'api',
  }]
}

interface ExpectedEndpoint { verb: string; routePath: string }
function expectedScreenEndpoints(view: 'list' | 'detail' | 'form', _entity: string): ExpectedEndpoint[] {
  switch (view) {
    case 'list':   return [{ verb: 'get',  routePath: 'list' }]
    case 'detail': return [{ verb: 'get',  routePath: 'detail/{id}' }]
    case 'form':   return [
      { verb: 'post', routePath: 'form' },
      { verb: 'put',  routePath: 'form/{id}' },
    ]
  }
}

// ─── DEV-API-013 ──────────────────────────────────────────────────────────
// No `// TODO[SCREEN-` markers may survive Phase 2b of /ba-develop.

function checkDevApi013(todos: ScreenTodoEntry[]): Finding[] {
  if (todos.length === 0) {
    return [{
      dimension: 'devApi',
      code: 'DEV-API-013',
      severity: 'ok',
      label: 'DEV_API_013_ok',
      params: {},
      solution: '',
    }]
  }
  return [{
    dimension: 'devApi',
    code: 'DEV-API-013',
    severity: 'err',
    label: 'DEV_API_013_err',
    params: {
      count: todos.length,
      files: Array.from(new Set(todos.map(t => t.file))).join(','),
      details: todos,
    },
    solution: 'Each `// TODO[SCREEN-<screenCode>]:` marker means scaffold-screen-controller could not prove the Business method exists or the DTO mapping is incomplete. The Phase 2b subagent must add the missing method to I{Entity}Service (reusing Phase 2a query primitives — never duplicate rules) and replace the marker with the real LINQ projection.',
    fixSkill: 'backend-screen-controller',
    fixPhaseKey: 'api',
  }]
}

// ─── DEV-API-014 ──────────────────────────────────────────────────────────
// Cross-strata service injection coherence: when both an integration and a
// screen controller exist for the same entity, they MUST inject the same
// I{Entity}Service. A divergence means business rules can drift between strata.

function checkDevApi014(endpoints: ControllerEndpointEntry[]): Finding[] {
  // Group endpoints by entity → {integration: service, screens: service}.
  const byEntity = new Map<string, { integration?: string | null; screens?: string | null }>()
  for (const e of endpoints) {
    const slot = byEntity.get(e.entity) ?? {}
    if (e.stratum === 'integration') slot.integration = slot.integration ?? e.injectedService
    else slot.screens = slot.screens ?? e.injectedService
    byEntity.set(e.entity, slot)
  }

  const mismatches: Array<{ entity: string; integration: string; screens: string }> = []
  for (const [entity, slot] of byEntity) {
    if (!slot.integration || !slot.screens) continue // only check entities present in BOTH strata
    if (slot.integration !== slot.screens) {
      mismatches.push({ entity, integration: slot.integration, screens: slot.screens })
    }
  }

  if (mismatches.length === 0) {
    return [{
      dimension: 'devApi',
      code: 'DEV-API-014',
      severity: 'ok',
      label: 'DEV_API_014_ok',
      params: { entities: Array.from(byEntity.keys()).length },
      solution: '',
    }]
  }
  return [{
    dimension: 'devApi',
    code: 'DEV-API-014',
    severity: 'err',
    label: 'DEV_API_014_err',
    params: {
      entities: mismatches.map(m => `${m.entity}:integration=${m.integration},screens=${m.screens}`).join(','),
      details: mismatches,
    },
    solution: 'Both strata must inject the same I{Entity}Service. If the screen controller needs methods not on the integration interface, extend the EXISTING interface — do not create a parallel service. Business rules live in a single Business layer.',
    fixSkill: 'backend-screen-controller',
    fixPhaseKey: 'api',
  }]
}

function singularize(s: string): string {
  if (s.endsWith('ies')) return s.slice(0, -3) + 'y'
  if (s.endsWith('s') && !s.endsWith('ss')) return s.slice(0, -1)
  return s
}

function normaliseControllerRoute(route: string): string {
  return route.replace(/^\/+|\/+$/g, '').replace(/\{(\w+)(?::[^}]+)?\}/g, '{$1}')
}

function normaliseRoute(route: string): string {
  return route.replace(/^\/+|\/+$/g, '').replace(/\{(\w+)(?::[^}]+)?\}/g, '{$1}')
}

function isCrudShape(route: string): boolean {
  return route === '' || route === '{id}'
}

// ─── Markdown renderer ────────────────────────────────────────────────────

function renderMarkdown(input: {
  counts: { errors: number; warnings: number; oks: number }
  findings: Finding[]
  args: AuditDevApiArgs
}): string {
  const { counts, findings, args } = input
  const verdict = counts.errors === 0 ? '✅ PASS' : '❌ FAIL'
  const lines: string[] = []
  lines.push(`# audit-dev-api — ${args.appCode}/${args.moduleCode}`)
  lines.push('')
  lines.push(`**Verdict** : ${verdict} · ${counts.errors} err · ${counts.warnings} warn · ${counts.oks} ok`)
  lines.push('')
  lines.push('## Findings')
  lines.push('')
  for (const f of findings) {
    const icon = f.severity === 'err' ? '❌' : f.severity === 'warn' ? '⚠️' : '✅'
    lines.push(`### ${icon} ${f.code} — ${f.label}`)
    if (Object.keys(f.params).length > 0) {
      lines.push('```json')
      lines.push(JSON.stringify(f.params, null, 2))
      lines.push('```')
    }
    if (f.solution) {
      lines.push('')
      lines.push(`**Solution** : ${f.solution}`)
    }
    lines.push('')
  }
  return lines.join('\n')
}

export function reportPath(backendPath: string, moduleCode: string): string {
  return path.join(backendPath, '_audit', `dev-api-${moduleCode}.md`)
}
