/**
 * audit-dev-wire / audit.ts — Static parity check between the generated
 * frontend service URLs and the generated backend controller routes.
 *
 * MVP scope (Wave 2.1, 2026-05-27):
 *   - DEV-WIRE-001: frontend-orphan (BLOCKING)   — primary 404 detector
 *   - DEV-WIRE-001: backend-orphan  (INFO/warn)  — cleanup candidate
 *
 * Reserved (not yet implemented — captured in SKILL.md for forward compat):
 *   - DEV-WIRE-002 verb-mismatch
 *   - DEV-WIRE-003 strata-mismatch
 *
 * Frontend extraction is regex-driven (not a TS AST parser) — fast enough to
 * scan a whole feature folder in milliseconds and resilient to small syntax
 * shifts in the generated services (which are themselves regex-emitted by
 * scaffold-api-client).
 */
import path from 'node:path'
import { findFiles, readText } from '../../../../lib/fs.js'
import { canonicaliseRoute } from '../../../../lib/url-conventions.js'
import type {
  AuditDevWireArgs,
  AuditReport,
  BackendRoute,
  Finding,
  FrontendCall,
} from './types.js'

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

export async function audit(args: AuditDevWireArgs): Promise<AuditReport> {
  const frontendCalls = await loadFrontendCalls(path.resolve(args.webPath))
  const backendRoutes = await loadBackendRoutes(path.resolve(args.backendPath))

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

  if (runAll || ruleFilter.has('DEV-WIRE-001')) {
    findings.push(...checkDevWire001(frontendCalls, backendRoutes))
  }

  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, frontendCalls, backendRoutes })

  return {
    scope: 'devWire',
    applicationCode: args.appCode,
    moduleCode: args.moduleCode,
    backendPath: args.backendPath,
    webPath: args.webPath,
    filesInspected: frontendCalls.length + backendRoutes.length,
    findings,
    counts,
    byRule,
    markdown,
  }
}

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

// ─── DEV-WIRE-001 ──────────────────────────────────────────────────────────

function checkDevWire001(
  frontendCalls: FrontendCall[],
  backendRoutes: BackendRoute[],
): Finding[] {
  const findings: Finding[] = []

  // Index backend routes by normalised key.
  const backendIndex = new Map<string, BackendRoute>()
  for (const r of backendRoutes) {
    backendIndex.set(routeKey(r.verb, r.urlPath), r)
  }

  // 1) frontend-orphan: every frontend call must find a backend route.
  for (const call of frontendCalls) {
    const key = routeKey(call.verb, call.urlPath)
    if (backendIndex.has(key)) continue

    const stratum = canonicaliseRoute(call.urlPath).stratum
    const fixSkill =
      stratum === 'screens' ? 'backend-screen-controller'
      : stratum === 'integration' ? 'backend-controller'
      : 'backend-screen-controller' // unknown stratum → re-scaffold likely missing screen controller
    findings.push({
      dimension: 'devWire',
      code: 'DEV-WIRE-001',
      severity: 'err',
      label: 'DEV_WIRE_001_err',
      params: {
        calls: `${call.verb.toUpperCase()} ${call.urlPath}|${call.file}:${call.line}`,
        stratum,
      },
      solution: stratum === 'unknown'
        ? `Frontend calls \`${call.verb.toUpperCase()} ${call.urlPath}\` from ${call.file}:${call.line} but the URL doesn't match either canonical stratum (/api/v1/integration/* or /api/screens/*). Re-run scaffold-api-client for this entity — the URL was probably emitted by an obsolete \`apiBasePath\` template (removed on 2026-05-27).`
        : `Frontend calls \`${call.verb.toUpperCase()} ${call.urlPath}\` from ${call.file}:${call.line} but no backend route matches. Re-invoke the appropriate scaffolder (${fixSkill}) for the entity to emit the missing route.`,
      fixSkill,
      fixPhaseKey: 'api',
      file: call.file,
    })
  }

  // 2) backend-orphan: every backend route should be called by ≥ 1 frontend
  //    service. Informational only — lookup / health probes legitimately have
  //    no frontend caller.
  const frontendIndex = new Set<string>()
  for (const c of frontendCalls) frontendIndex.add(routeKey(c.verb, c.urlPath))
  for (const route of backendRoutes) {
    const key = routeKey(route.verb, route.urlPath)
    if (frontendIndex.has(key)) continue
    // Skip well-known no-caller endpoints — they are legitimately backend-only.
    if (/\/lookup$/.test(route.urlPath)) continue
    if (/\/health/.test(route.urlPath)) continue
    findings.push({
      dimension: 'devWire',
      code: 'DEV-WIRE-001',
      severity: 'warn',
      label: 'DEV_WIRE_001_warn',
      params: {
        route: `${route.verb.toUpperCase()} ${route.urlPath}|${route.file}`,
        stratum: route.stratum,
      },
      solution: `Backend route \`${route.verb.toUpperCase()} ${route.urlPath}\` (${route.file}) has no frontend caller. Informational — confirm it's intended for machine-to-machine use or remove it.`,
      file: route.file,
    })
  }

  if (findings.length === 0) {
    findings.push({
      dimension: 'devWire',
      code: 'DEV-WIRE-001',
      severity: 'ok',
      label: 'DEV_WIRE_001_ok',
      params: { calls: frontendCalls.length, routes: backendRoutes.length },
      solution: '',
    })
  }

  return findings
}

function routeKey(verb: string, urlPath: string): string {
  return `${verb.toLowerCase()} ${normalisePath(urlPath)}`
}

/** Collapse `{id:guid}` / `{id}` / `${var}` placeholders into a uniform `{p}`
 *  so frontend and backend can match by structure rather than literal text. */
function normalisePath(p: string): string {
  return p
    .replace(/\$\{[^}]+\}/g, '{p}')         // TS template substitutions
    .replace(/\{[^:}]+:[^}]+\}/g, '{p}')    // ASP.NET typed segments `{id:guid}`
    .replace(/\{[^}]+\}/g, '{p}')           // bare ASP.NET segments `{id}`
    .replace(/\/+/g, '/')
    .replace(/\/$/, '')
}

// ─── Frontend call loader ──────────────────────────────────────────────────

const API_PATH_DECL_RE = /const\s+(API_PATH|INTEGRATION_PATH)\s*=\s*['"]([^'"]+)['"]/g
const API_CALL_RE = /(?:^|\W)(?:api|client)\.(get|post|put|patch|delete)\s*<[^>]*>?\s*\(\s*(`[^`]+`|'[^']+'|"[^"]+"|[A-Z_]+)/g

async function loadFrontendCalls(webPath: string): Promise<FrontendCall[]> {
  const out: FrontendCall[] = []
  // Generated services + hooks. Both can call the API.
  const candidates = [
    ...(await findFiles('**/services/*Service.ts', { cwd: webPath })),
    ...(await findFiles('**/hooks/use*.ts', { cwd: webPath })),
  ]
  for (const abs of candidates) {
    const source = await readText(abs)
    if (!source) continue
    const rel = path.relative(webPath, abs).replace(/\\/g, '/')

    // 1) Capture the URL-base constants declared at the top of the file.
    const baseLookup: Record<string, string> = {}
    let bm: RegExpExecArray | null
    API_PATH_DECL_RE.lastIndex = 0
    while ((bm = API_PATH_DECL_RE.exec(source)) !== null) {
      baseLookup[bm[1]] = bm[2]
    }

    // 2) Walk every api.<verb>(...) and pin its line number.
    const lines = source.split(/\r?\n/)
    let cursor = 0
    for (let i = 0; i < lines.length; i++) {
      const lineStart = cursor
      const lineEnd = cursor + lines[i].length
      cursor = lineEnd + 1
      // Locate calls anchored on THIS line. We re-scan the whole `source`
      // because some calls are wrapped across lines — the line of the verb
      // itself is what matters for navigation.
      const lineSource = lines[i]
      let cm: RegExpExecArray | null
      const lineRe = new RegExp(API_CALL_RE.source, 'g')
      while ((cm = lineRe.exec(lineSource)) !== null) {
        const verb = cm[1].toLowerCase()
        const rawUrl = cm[2]
        const urlPath = resolveUrl(rawUrl, baseLookup)
        if (!urlPath) continue
        // Skip absolute external URLs (not our concern).
        if (!urlPath.startsWith('/api')) continue
        out.push({ verb, urlPath, file: rel, line: i + 1 })
      }
      // Avoid unused-variable warning.
      void lineStart
    }
  }
  return out
}

function resolveUrl(raw: string, baseLookup: Record<string, string>): string | null {
  // 1) Template literal: `${BASE}/suffix` or `${BASE}/${id}`
  if (raw.startsWith('`')) {
    const inner = raw.slice(1, -1)
    // Replace ${BASE} with the captured base, leave other ${...} as placeholders.
    return inner.replace(/\$\{([A-Z_]+)\}/g, (_match, name) => baseLookup[name] ?? `\${${name}}`)
  }
  // 2) Plain string literal.
  if (raw.startsWith("'") || raw.startsWith('"')) {
    return raw.slice(1, -1)
  }
  // 3) Bare identifier like `API_PATH` — substitute.
  return baseLookup[raw] ?? null
}

// ─── Backend route loader (mirrors audit-dev-api/audit.ts pattern) ─────────

const C_HTTP_VERBS = ['Get', 'Post', 'Put', 'Delete', 'Patch']
const CLASS_ROUTE_RE = /\[Route\(\s*"([^"]+)"\s*\)\]/

async function loadBackendRoutes(backendPath: string): Promise<BackendRoute[]> {
  const out: BackendRoute[] = []
  const files = await findFiles('**/*Controller.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 classRouteMatch = CLASS_ROUTE_RE.exec(source)
    const classRouteRaw = classRouteMatch ? classRouteMatch[1] : ''
    const classRoute = classRouteRaw.startsWith('/') ? classRouteRaw : `/${classRouteRaw}`
    const stratumInfo = canonicaliseRoute(classRoute)
    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 suffix = (m[2] ?? '').trim()
      const urlPath = joinRoute(classRoute, suffix)
      out.push({ verb, urlPath, file: rel, stratum: stratumInfo.stratum })
    }
  }
  return out
}

function joinRoute(classRoute: string, suffix: string): string {
  const base = classRoute.replace(/\/$/, '')
  if (!suffix) return base
  return `${base}/${suffix.replace(/^\//, '')}`
}

// ─── Markdown rendering ────────────────────────────────────────────────────

function renderMarkdown(args: {
  counts: { errors: number; warnings: number; oks: number }
  findings: Finding[]
  args: AuditDevWireArgs
  frontendCalls: FrontendCall[]
  backendRoutes: BackendRoute[]
}): string {
  const { counts, findings, args: a, frontendCalls, backendRoutes } = args
  const lines: string[] = []
  lines.push(`# audit-dev-wire — ${a.appCode}/${a.moduleCode}`)
  lines.push('')
  lines.push(`- Errors: **${counts.errors}**`)
  lines.push(`- Warnings: ${counts.warnings}`)
  lines.push(`- OKs: ${counts.oks}`)
  lines.push(`- Frontend calls scanned: ${frontendCalls.length}`)
  lines.push(`- Backend routes scanned: ${backendRoutes.length}`)
  lines.push('')
  if (findings.length === 0) {
    lines.push('No findings.')
    return lines.join('\n')
  }
  lines.push('| Code | Severity | Details |')
  lines.push('|------|----------|---------|')
  for (const f of findings) {
    const details = Object.entries(f.params)
      .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
      .join(' • ')
    lines.push(`| ${f.code} | ${f.severity} | ${details} |`)
  }
  lines.push('')
  return lines.join('\n')
}
