/**
 * audit-dev-actions-alignment / audit.ts
 *
 * Cross-references three sources of truth for custom action URLs:
 *   - pagespec.actions[].endpoint                (BA contract)
 *   - <Entity>Controller.cs [HttpVerb("…")]      (backend reality)
 *   - <entity>Service.ts api.<verb>(URL, …)      (frontend reality)
 *
 * Emits one finding per drift. The CLI is read-only — `apply.ts` (separate)
 * does the rewriting.
 */
import path from 'node:path'
import { findFiles, readText, fileExists } from '../../../../lib/fs.js'
import {
  STANDARD_CRUD_CODES,
  defaultEndpoint,
  expectedControllerRoute,
  serviceMethodNameFromEndpoint,
} from '../../../../lib/page-spec-actions.js'
import type {
  ActionAlignmentArgs,
  AlignmentFinding,
  AlignmentReport,
  ControllerEndpointEntry,
  PageActionEntry,
  ServiceCallEntry,
} from './types.js'

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

export async function audit(args: ActionAlignmentArgs): Promise<AlignmentReport> {
  const pageActions = args.modulePath
    ? await loadPageActions(path.resolve(args.modulePath))
    : await discoverPageActions(path.resolve(args.projectPath))
  const controllerEndpoints = await loadControllerEndpoints(path.resolve(args.backendPath))
  const serviceCalls = await loadServiceCalls(path.resolve(args.projectPath))

  const findings = crossReference(pageActions, controllerEndpoints, serviceCalls)

  const counts = { err: 0, warn: 0, ok: 0 }
  for (const f of findings) counts[f.severity]++

  const entities = new Set<string>()
  for (const a of pageActions) entities.add(a.entity)
  for (const e of controllerEndpoints) entities.add(e.entity)
  for (const s of serviceCalls) entities.add(s.entity)

  const markdown = renderMarkdown({
    counts,
    findings,
    inventory: {
      pageActions: pageActions.length,
      controllerEndpoints: controllerEndpoints.length,
      serviceCalls: serviceCalls.length,
      entitiesCovered: Array.from(entities).sort(),
    },
    args,
  })

  return {
    counts,
    inventory: {
      pageActions: pageActions.length,
      controllerEndpoints: controllerEndpoints.length,
      serviceCalls: serviceCalls.length,
      entitiesCovered: Array.from(entities).sort(),
    },
    findings,
    markdown,
  }
}

// ─── Pagespec loader ──────────────────────────────────────────────────────

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

/** Load every `pagespecs/*.md` directly under modulePath. */
export 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)
    const entries = parsePagespecActions(source, entity, filename)
    out.push(...entries)
  }
  return out
}

/** Best-effort discovery: scan up to 3 levels of `.smartstack/ba/**` under projectPath. */
export async function discoverPageActions(projectPath: string): Promise<PageActionEntry[]> {
  const out: PageActionEntry[] = []
  const files = await findFiles('.smartstack/ba/**/pagespecs/*.md', { cwd: projectPath })
  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
}

/** Extract the fenced ```json block of a pagespec and pull its `actions[]`. */
export function parsePagespecActions(
  source: string,
  entity: string,
  filename: string,
): PageActionEntry[] {
  const blocks: string[] = []
  // The dotAll flag is broadly supported; use [\s\S] to stay safe for older engines too.
  const re = /```json\s*\n([\s\S]*?)\n```/g
  let m: RegExpExecArray | null
  while ((m = re.exec(source)) !== null) blocks.push(m[1])
  if (blocks.length === 0) return []

  const entries: PageActionEntry[] = []
  for (const block of blocks) {
    let parsed: unknown
    try {
      parsed = JSON.parse(block)
    } catch {
      continue // malformed JSON — PRD-001′ will flag it; we just skip here
    }
    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
}

// ─── Controller loader (mirrors audit-dev-frontend audit.ts:678) ──────────

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

export 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 fileMatch = CONTROLLER_FILE_RE.exec(filename)
    if (!fileMatch) continue
    const source = await readText(abs)
    if (!source) continue
    // Prefer the `I<Entity>Service` field/parameter pattern over the filename
    // because pluralised controller filenames (`TypesAffaireController.cs` for
    // entity `TypeAffaire`) lose information that the singularization heuristic
    // cannot reliably recover.
    const serviceMatch = CONTROLLER_SERVICE_FIELD_RE.exec(source)
    const entity = serviceMatch ? serviceMatch[1] : singularize(fileMatch[1])
    const rel = path.relative(backendPath, abs).replace(/\\/g, '/')
    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 })
    }
  }
  return out
}

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

// ─── Service loader (mirrors audit-dev-frontend audit.ts:726) ─────────────

const SERVICE_FILE_RE = /([a-z][A-Za-z0-9]*)Service\.ts$/

export async function loadServiceCalls(projectPath: string): Promise<ServiceCallEntry[]> {
  const out: ServiceCallEntry[] = []
  // Loose glob — `**/*Service.ts` mirrors the controller pattern. Without the
  // structural prefix `src/features/**/services/` the audit picks up service
  // files regardless of layout (handy for legacy projects whose services live
  // under `src/services/foo/fooService.ts` instead of `src/features/...`).
  const found = new Set<string>()
  for (const pat of ['**/*Service.ts']) {
    for (const f of await findFiles(pat, { cwd: projectPath })) {
      const rel = path.relative(projectPath, f).replace(/\\/g, '/')
      // Skip non-service TS that happen to end with "Service.ts" — keep only files
      // under a src/ tree to exclude test fixtures, scripts, dist, …
      if (!rel.startsWith('src/')) continue
      found.add(rel)
    }
  }
  for (const rel of found) {
    const abs = path.join(projectPath, rel)
    const filename = path.basename(rel)
    const fileMatch = SERVICE_FILE_RE.exec(filename)
    const entity = fileMatch
      ? fileMatch[1].charAt(0).toUpperCase() + fileMatch[1].slice(1)
      : '?'
    const source = await readText(abs)
    if (!source) continue
    const callRe = /\b(?:apiClient|axios|client|http)\s*\.\s*(get|post|put|delete|patch)\s*(?:<[^>]*>)?\s*\(\s*([`'"])([^`'"]+)\2/g
    let m: RegExpExecArray | null
    while ((m = callRe.exec(source)) !== null) {
      const verb = m[1].toLowerCase()
      const rawUrl = m[3]
      const urlPath = normaliseServiceUrl(rawUrl)
      out.push({ verb, urlPath, file: rel, entity })
    }
  }
  return out
}

function normaliseServiceUrl(url: string): string {
  // Drop the leading `${API_PATH}` (or any all-uppercase const referenced via
  // template literal) — it's a path prefix variable, not an id. Then collapse
  // id-shaped interpolations (`${id}`, `${item.id}`, `${budgetId}`, …) to the
  // canonical `{id}` token used by controller route matching.
  return url
    .replace(/^\$\{[A-Z_][A-Z0-9_]*\}\/?/, '')
    .replace(/\$\{[^}]*\b[iI]d\b[^}]*\}/g, '{id}')
    .replace(/\$\{[^}]+\}/g, '{id}')  // last-resort fallback (anything left is treated as an id)
    .replace(/^\/+|\/+$/g, '')
    .replace(/^api\//, '')
}

/**
 * Tolerant route extraction from a service URL — used by the matcher.
 *
 * Service URLs come in two flavours after normalisation:
 *   - With explicit prefix:    `referentiels/types-affaire/sync-from-proconcept`
 *   - With stripped `${API_PATH}` prefix:   `sync-from-proconcept`
 *
 * We can't know a priori which the caller produced, so we accept BOTH by
 * checking whether the URL ENDS with the expected controller route. The
 * audit logic uses this via `urlEndsWithRoute(call.urlPath, expectedRoute)`
 * which is more permissive than the strict-equality match the controller
 * side uses.
 *
 *   urlEndsWithRoute('referentiels/types-affaire/sync-from-proconcept', 'sync-from-proconcept') === true
 *   urlEndsWithRoute('sync-from-proconcept', 'sync-from-proconcept') === true
 *   urlEndsWithRoute('referentiels/types-affaire/sync-from-pce', 'sync-from-proconcept') === false
 *   urlEndsWithRoute('referentiels/types-affaire/{id}/archive', '{id}/archive') === true
 */
function urlEndsWithRoute(urlPath: string, route: string): boolean {
  if (urlPath === route) return true
  return urlPath.endsWith('/' + route)
}

// ─── Cross-reference logic ───────────────────────────────────────────────

const CRUD_PATH_RE = /^(?:[a-z][a-z0-9-]*\/)?\{id\}?$|^[a-z][a-z0-9-]*$/

export function crossReference(
  pageActions: PageActionEntry[],
  controllerEndpoints: ControllerEndpointEntry[],
  serviceCalls: ServiceCallEntry[],
): AlignmentFinding[] {
  const findings: AlignmentFinding[] = []

  // Group endpoints + calls by entity to scope comparisons.
  const endpointsByEntity = groupBy(controllerEndpoints, e => e.entity)
  const callsByEntity = groupBy(serviceCalls, c => c.entity)
  const actionsByEntity = groupBy(pageActions, a => a.entity)

  for (const [entity, actions] of actionsByEntity.entries()) {
    const eps = endpointsByEntity.get(entity) ?? []
    const calls = callsByEntity.get(entity) ?? []
    // Track service calls already associated to a pagespec action so the
    // dead-call pass doesn't re-flag them. Same for controller endpoints.
    const flaggedCalls = new Set<ServiceCallEntry>()
    const flaggedEndpoints = new Set<ControllerEndpointEntry>()

    for (const action of actions) {
      const expectedRoute = expectedControllerRoute(action.scope, action.endpoint)
      const expectedVerb = action.httpMethod.toLowerCase()

      // ─── Pagespec ↔ Controller alignment ───
      const matchingController = eps.find(
        e => e.verb === expectedVerb && e.routePath === expectedRoute,
      )
      if (matchingController) {
        flaggedEndpoints.add(matchingController)
      } else {
        // Did we find ANY non-CRUD endpoint on the controller carrying a similar
        // SUFFIX (sync-from-* vs sync-from-pce)? Surface that as a drift, otherwise
        // emit "no candidate" — different rule shape.
        const candidate = eps.find(
          e =>
            e.verb === expectedVerb &&
            !isCrudPath(e.routePath) &&
            sameScope(e.routePath, action.scope) &&
            similar(stripScope(e.routePath, action.scope), action.endpoint),
        )
        if (candidate) {
          flaggedEndpoints.add(candidate)
          findings.push(driftPagespecVsController(action, candidate))
        } else {
          findings.push(noControllerMatch(action))
        }
      }

      // ─── Pagespec ↔ Service alignment ───
      // Service URLs may keep the `<module>/<plural>/` prefix or not — that
      // depends on whether the file uses an `${API_PATH}` template literal
      // (audit strips that) or a hard-coded literal (audit keeps it). We
      // accept BOTH by matching on the URL suffix.
      const matchingService = calls.find(
        c => c.verb === expectedVerb && urlEndsWithRoute(c.urlPath, expectedRoute),
      )
      if (matchingService) {
        flaggedCalls.add(matchingService)
      } else {
        const candidate = calls.find(c => {
          // Anything ending with a non-CRUD path whose suffix shares tokens
          // with the expected endpoint is treated as a near-miss.
          if (isCrudUrlPath(c.urlPath)) return false
          const lastSegment = c.urlPath.split('/').filter(Boolean).pop() ?? ''
          return similar(lastSegment, action.endpoint)
        })
        if (candidate) {
          flaggedCalls.add(candidate)
          if (candidate.verb !== expectedVerb) {
            findings.push(driftVerbMismatch(action, candidate))
          } else {
            findings.push(driftPagespecVsService(action, candidate))
          }
        } else {
          findings.push(noServiceMatch(action))
        }
      }
    }

    // ─── Dead service calls (no pagespec, no controller backing) ───
    for (const call of calls) {
      if (isCrudUrlPath(call.urlPath)) continue
      if (flaggedCalls.has(call)) continue // already surfaced as a drift above
      const inControllers = eps.some(
        e => e.verb === call.verb && urlEndsWithRoute(call.urlPath, e.routePath),
      )
      const inPagespecs = actions.some(
        a =>
          a.httpMethod.toLowerCase() === call.verb &&
          urlEndsWithRoute(call.urlPath, expectedControllerRoute(a.scope, a.endpoint)),
      )
      if (!inControllers && !inPagespecs) {
        findings.push(deadServiceCall(call))
      }
    }

    // ─── Dead controller endpoints ───
    for (const ep of eps) {
      if (isCrudPath(ep.routePath)) continue
      if (flaggedEndpoints.has(ep)) continue // already surfaced as a drift above
      const inPagespecs = actions.some(
        a =>
          a.httpMethod.toLowerCase() === ep.verb &&
          expectedControllerRoute(a.scope, a.endpoint) === ep.routePath,
      )
      const inServices = calls.some(
        c => c.verb === ep.verb && urlEndsWithRoute(c.urlPath, ep.routePath),
      )
      if (!inPagespecs && !inServices) {
        findings.push(deadControllerEndpoint(ep))
      }
    }
  }

  if (findings.length === 0) {
    findings.push({
      code: 'ACTION-DRIFT-OK',
      severity: 'ok',
      entity: '*',
      message: 'No drift detected — every custom action is aligned across pagespec / controller / service.',
      suggestion: 'Re-run after the next pagespec or controller change.',
    })
  }

  return findings
}

// ─── Finding constructors ────────────────────────────────────────────────

function driftPagespecVsController(
  action: PageActionEntry,
  controller: ControllerEndpointEntry,
): AlignmentFinding {
  const expectedRoute = expectedControllerRoute(action.scope, action.endpoint)
  return {
    code: 'ACTION-DRIFT-001',
    severity: 'err',
    entity: action.entity,
    message:
      `Pagespec endpoint "${action.endpoint}" (scope=${action.scope}, ${action.httpMethod}) does not match ` +
      `controller route "${controller.routePath}" on ${controller.file}.`,
    suggestion:
      'Either update the pagespec `endpoint` to match the controller, OR rename the controller route ' +
      'to match the pagespec. The pagespec is the contract — pick which side should win and run ' +
      '`--mode apply --source-of-truth=<pagespec|controller>`.',
    detail: {
      pageActionEndpoint: action.endpoint,
      controllerRoute: controller.routePath,
      pagespecFile: action.file,
      controllerFile: controller.file,
      verb: action.httpMethod,
      scope: action.scope,
    },
  }
}

function driftPagespecVsService(
  action: PageActionEntry,
  call: ServiceCallEntry,
): AlignmentFinding {
  return {
    code: 'ACTION-DRIFT-002',
    severity: 'err',
    entity: action.entity,
    message:
      `Pagespec endpoint "${action.endpoint}" but service ${call.file} calls "${call.urlPath}" — ` +
      `they MUST be identical. This is the "POST /sync-from-pce → 405" drift pattern.`,
    suggestion:
      'Re-run scaffold-api-client for this entity after re-applying the pagespec — the service ' +
      'should never hardcode a URL that diverges from the pagespec. Or, if the BA was wrong, update ' +
      'the pagespec to match the service.',
    detail: {
      pageActionEndpoint: action.endpoint,
      serviceUrl: call.urlPath,
      pagespecFile: action.file,
      serviceFile: call.file,
      verb: action.httpMethod,
      scope: action.scope,
    },
  }
}

function driftVerbMismatch(action: PageActionEntry, call: ServiceCallEntry): AlignmentFinding {
  return {
    code: 'ACTION-DRIFT-005',
    severity: 'err',
    entity: action.entity,
    message:
      `Service ${call.file} uses ${call.verb.toUpperCase()} on "${call.urlPath}" but the pagespec ` +
      `(or matching controller) declares ${action.httpMethod} — verb mismatch produces 405. ` +
      'This is the "GET /impact vs POST /analyze-impact" drift pattern.',
    suggestion:
      'Pick the verb declared in the pagespec and re-run scaffold-api-client. If the BA actually wants ' +
      'a different verb, update the pagespec `httpMethod` first.',
    detail: {
      pageActionEndpoint: action.endpoint,
      serviceUrl: call.urlPath,
      pagespecFile: action.file,
      serviceFile: call.file,
      verb: action.httpMethod,
      scope: action.scope,
    },
  }
}

function noControllerMatch(action: PageActionEntry): AlignmentFinding {
  const expectedRoute = expectedControllerRoute(action.scope, action.endpoint)
  return {
    code: 'ACTION-DRIFT-001',
    severity: 'err',
    entity: action.entity,
    message:
      `Pagespec "${action.code}" expects ${action.httpMethod} ${expectedRoute} but no controller method ` +
      'matches. The endpoint is missing entirely from the backend.',
    suggestion:
      'Re-run Phase 2 of `/ba-develop`. The orchestrator will derive `customActions[]` from the ' +
      'pagespec and forward them to scaffold-controller, which emits the route attribute.',
    detail: {
      pageActionEndpoint: action.endpoint,
      pagespecFile: action.file,
      verb: action.httpMethod,
      scope: action.scope,
    },
  }
}

function noServiceMatch(action: PageActionEntry): AlignmentFinding {
  return {
    code: 'ACTION-DRIFT-002',
    severity: 'err',
    entity: action.entity,
    message:
      `Pagespec "${action.code}" expects a service method "${serviceMethodNameFromEndpoint(action.endpoint)}" ` +
      `on ${entityServiceFile(action.entity)} but no axios call matches. The frontend is not wired.`,
    suggestion:
      'Re-run Phase 3a of `/ba-develop` — scaffold-api-client emits the service method + React Query hook ' +
      'from the pagespec contract.',
    detail: {
      pageActionEndpoint: action.endpoint,
      pagespecFile: action.file,
      verb: action.httpMethod,
      scope: action.scope,
    },
  }
}

function deadServiceCall(call: ServiceCallEntry): AlignmentFinding {
  return {
    code: 'ACTION-DRIFT-003',
    severity: 'warn',
    entity: call.entity,
    message:
      `Service ${call.file} calls ${call.verb.toUpperCase()} /api/.../${call.urlPath} but no pagespec ` +
      'declares it AND no controller exposes it. This is a dead call (will 404 at runtime).',
    suggestion:
      'Either declare the action in the pagespec (then re-run `/ba-develop`) or remove the orphan axios call.',
    detail: { serviceUrl: call.urlPath, serviceFile: call.file, verb: call.verb.toUpperCase() },
  }
}

function deadControllerEndpoint(ep: ControllerEndpointEntry): AlignmentFinding {
  return {
    code: 'ACTION-DRIFT-004',
    severity: 'warn',
    entity: ep.entity,
    message:
      `Controller ${ep.file} exposes ${ep.verb.toUpperCase()} ${ep.routePath} but no pagespec references it ` +
      'AND no service calls it. This is a dead endpoint.',
    suggestion:
      'Either declare the action in the pagespec (then re-run `/ba-develop`) so the frontend wires up, ' +
      'or delete the orphan controller method.',
    detail: { controllerRoute: ep.routePath, controllerFile: ep.file, verb: ep.verb.toUpperCase() },
  }
}

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

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

interface PageActionShape {
  code: string
  kind?: 'api' | 'navigate'
  scope: 'row' | 'bulk' | 'header'
  endpoint?: string
  httpMethod?: string
  ucReference?: string
}

function isPageSpec(value: unknown): value is PageSpecShape {
  return typeof value === 'object' && value !== null && 'entity' in (value as object)
}

function isAction(value: unknown): value is PageActionShape {
  if (typeof value !== 'object' || value === null) return false
  const v = value as { code?: unknown; scope?: unknown }
  return typeof v.code === 'string' && typeof v.scope === 'string'
}

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

function groupBy<T, K>(items: T[], keyFn: (item: T) => K): Map<K, T[]> {
  const out = new Map<K, T[]>()
  for (const item of items) {
    const k = keyFn(item)
    const bucket = out.get(k)
    if (bucket) bucket.push(item)
    else out.set(k, [item])
  }
  return out
}

function singularize(plural: string): string {
  // Very rough — controllers are named Plural (Budgets, Contacts, TypesAffaire).
  // We strip trailing 's' for canonical CRUD plurals; complex cases stay as-is.
  if (plural.endsWith('ies')) return plural.slice(0, -3) + 'y'
  if (plural.endsWith('s') && !plural.endsWith('ss')) return plural.slice(0, -1)
  return plural
}

function isCrudPath(routePath: string): boolean {
  return routePath === '' || routePath === '{id}'
}

function sameScope(routePath: string, scope: PageActionEntry['scope']): boolean {
  if (scope === 'row') return routePath.startsWith('{id}/')
  if (scope === 'bulk') return routePath.startsWith('bulk/')
  return !routePath.startsWith('{id}/') && !routePath.startsWith('bulk/')
}

function stripScope(routePath: string, scope: PageActionEntry['scope']): string {
  if (scope === 'row') return routePath.replace(/^\{id\}\//, '')
  if (scope === 'bulk') return routePath.replace(/^bulk\//, '')
  return routePath
}

/**
 * Detect a CRUD-only service URL (no custom action segment).
 *
 *   isCrudUrlPath('')                                          → true (root)
 *   isCrudUrlPath('referentiels/types-affaire')                → true (GET all)
 *   isCrudUrlPath('referentiels/types-affaire/{id}')           → true (GET by id)
 *   isCrudUrlPath('referentiels/types-affaire/sync-from-…')    → false
 *   isCrudUrlPath('sync-from-proconcept')                      → false
 *
 * The heuristic: the URL is CRUD if its final segment is either empty (root)
 * or `{id}` (resource by id). Anything else is a custom path that the audit
 * must track.
 */
function isCrudUrlPath(urlPath: string): boolean {
  if (urlPath === '') return true
  const segs = urlPath.split('/').filter(Boolean)
  const last = segs[segs.length - 1] ?? ''
  // `<module>/<plural>` (2 segments, neither matches a verb) — CRUD GET all / POST.
  if (segs.length === 2 && !/^\{id\}$/.test(last)) return true
  // `<module>/<plural>/{id}` — CRUD GET/PUT/DELETE by id.
  if (last === '{id}') return true
  return false
}

function entityServiceFile(entity: string): string {
  return `${entity.charAt(0).toLowerCase()}${entity.slice(1)}Service.ts`
}

/** Loose similarity: do the two endpoints share at least one kebab token? */
function similar(a: string, b: string): boolean {
  if (!a || !b) return false
  const ta = new Set(a.split('-'))
  const tb = b.split('-')
  return tb.some(t => ta.has(t))
}

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

interface RenderContext {
  counts: AlignmentReport['counts']
  inventory: AlignmentReport['inventory']
  findings: AlignmentFinding[]
  args: ActionAlignmentArgs
}

function renderMarkdown(ctx: RenderContext): string {
  const { counts, inventory, findings, args } = ctx
  const verdict = counts.err > 0 ? '❌ FAIL' : counts.warn > 0 ? '⚠️ WARN' : '✅ PASS'
  const date = new Date().toISOString().split('T')[0]

  const groups = groupBy(findings, f => f.code)
  const sections: string[] = []

  const ordered: Array<['ACTION-DRIFT-001' | 'ACTION-DRIFT-002' | 'ACTION-DRIFT-005' | 'ACTION-DRIFT-003' | 'ACTION-DRIFT-004' | 'ACTION-DRIFT-OK', string]> = [
    ['ACTION-DRIFT-001', 'Pagespec ↔ Controller drift (err)'],
    ['ACTION-DRIFT-002', 'Pagespec ↔ Service drift (err — endpoint drift)'],
    ['ACTION-DRIFT-005', 'Verb mismatch (err)'],
    ['ACTION-DRIFT-003', 'Dead service calls (warn)'],
    ['ACTION-DRIFT-004', 'Dead controller endpoints (warn)'],
    ['ACTION-DRIFT-OK', 'All aligned'],
  ]

  for (const [code, label] of ordered) {
    const list = groups.get(code) ?? []
    if (list.length === 0) continue
    const bullets = list
      .map(f => `- **${f.entity}** — ${f.message}\n  - 👉 ${f.suggestion}`)
      .join('\n')
    sections.push(`### ${label}\n\n${bullets}`)
  }

  return [
    `<!-- audit:actions-alignment -->`,
    `# Actions alignment audit`,
    `_${date} · Verdict : ${verdict} · ${counts.err} err · ${counts.warn} warn · ${counts.ok} ok_`,
    ``,
    `## Sources scanned`,
    ``,
    `| Source | Path | Count |`,
    `| --- | --- | --- |`,
    `| Pagespecs | \`${args.modulePath ?? '(discovered)'}\` | ${inventory.pageActions} actions |`,
    `| Controllers | \`${args.backendPath}\` | ${inventory.controllerEndpoints} routes |`,
    `| Services | \`${args.projectPath}\` | ${inventory.serviceCalls} calls |`,
    `| Entities covered | — | ${inventory.entitiesCovered.join(', ') || '(none)'} |`,
    ``,
    `## Findings`,
    ``,
    sections.join('\n\n'),
    ``,
    `## Next steps`,
    ``,
    counts.err === 0 && counts.warn === 0
      ? '- ✅ No alignment issues detected.'
      : [
          '- Pick a source-of-truth per finding (pagespec vs controller).',
          '- Run `--mode apply --source-of-truth=pagespec` to rewrite the controller routes, OR',
          '- Run `--mode apply --source-of-truth=controller` to rewrite the pagespec endpoints.',
          '- Then re-run `/ba-develop` to regenerate `service.ts` from the agreed pagespec.',
        ].join('\n'),
  ].join('\n')
}

/** Used by `index.ts` to know where to write the report. */
export function reportPath(projectPath: string): string {
  return path.join(projectPath, '_audit', 'actions-alignment.md')
}

/** Used by tests to assert the file landed. */
export async function reportExists(projectPath: string): Promise<boolean> {
  return fileExists(reportPath(projectPath))
}
