/**
 * uat-plan/discover.ts — Live discovery + correlation.
 *
 * Pulls the navigation skeleton, permissions and role grants from the running
 * app's DB (sql-discovery), reads the generated componentRegistry for the views
 * each nav node exposes, parses controllers for the API axis, filters everything
 * to the requested path scope, and resolves permissions against the LIVE permission
 * set (read→view→bare). The correlation helpers are PURE + unit-tested; `discover()`
 * is the thin I/O glue (the only part that touches SQL or the filesystem).
 */
import type { ParsedConnection } from '../../../lib/appsettings.js';
import { findFiles, readText } from '../../../lib/fs.js';
import {
  discoverNav,
  discoverPermissions,
  discoverRolePermissions,
  groupGrantsByRole,
  type NavNode,
} from '../../../lib/sql-discovery.js';
import { IMPLICIT_VIEW_SUFFIXES } from './project-roles.js';
import { computeSignature } from './generate.js';
import { discoverEndpoints, type ParsedEndpoint } from './discover-endpoints.js';
import type {
  DiscoveredNavRoute,
  DiscoveredEndpoint,
  DiscoveryResult,
  ImplicitView,
} from './types.js';

// ── PURE correlation helpers ────────────────────────────────────────────────

/** Scope path "administration/users" → component-key prefix "administration.users". */
export function pathToPrefix(path: string): string {
  return path
    .split('/')
    .map((s) => s.trim())
    .filter(Boolean)
    .join('.');
}

/** Is a component key within the scope prefix (itself or a dot-boundary descendant)? */
export function inScope(componentKey: string, prefix: string): boolean {
  return prefix === '' || componentKey === prefix || componentKey.startsWith(`${prefix}.`);
}

/** Build navId → dotted component key by walking each node's parent-code chain. PURE. */
export function buildComponentKeys(nodes: readonly NavNode[]): Map<string, string> {
  const byId = new Map(nodes.map((n) => [n.id, n]));
  const keyOf = new Map<string, string>();
  const compute = (n: NavNode, seen: Set<string>): string => {
    const cached = keyOf.get(n.id);
    if (cached) return cached;
    seen.add(n.id);
    const parent = n.parentId && !seen.has(n.parentId) ? byId.get(n.parentId) : undefined;
    const key = parent ? `${compute(parent, seen)}.${n.code}` : n.code;
    keyOf.set(n.id, key);
    return key;
  };
  for (const n of nodes) compute(n, new Set());
  return keyOf;
}

/**
 * Extract dotted component keys from `PageRegistry.register('a.b.c', …)`. PURE.
 * The `PAGE_KEYS.X` enum form is counted (pageKeysOnly) but not resolvable to a
 * dot-key without the enum table — the caller warns + degrades to nav-DB routes.
 */
export function parseRegistry(content: string): { keys: string[]; pageKeysOnly: number } {
  const keys: string[] = [];
  let pageKeysOnly = 0;
  const re = /PageRegistry\.register\(\s*(?:'([^']+)'|"([^"]+)"|(PAGE_KEYS\.\w+))/g;
  for (const m of content.matchAll(re)) {
    const key = m[1] ?? m[2];
    if (key) keys.push(key);
    else if (m[3]) pageKeysOnly++;
  }
  return { keys, pageKeysOnly };
}

/** Resolve a page's required permission against the live set: read → view → bare. PURE. */
export function resolvePagePermission(
  componentKey: string,
  permissionPaths: ReadonlySet<string>,
): string | undefined {
  for (const candidate of [`${componentKey}.read`, `${componentKey}.view`, componentKey]) {
    if (permissionPaths.has(candidate)) return candidate;
  }
  return undefined;
}

/** Candidate permission actions per HTTP verb. */
const VERB_ACTIONS: Record<string, string[]> = {
  GET: ['read', 'view'],
  POST: ['create'],
  PUT: ['update'],
  PATCH: ['update'],
  DELETE: ['delete'],
};

/**
 * Resolve an endpoint's required permission = `navRoute.action`, reconciled against
 * the live permission set; falls back to the primary-action candidate (even if not
 * in the set) so the endpoint stays gated rather than silently ungated. PURE.
 */
export function resolveEndpointPermission(
  navRoute: string | undefined,
  method: string,
  permissionPaths: ReadonlySet<string>,
): string | undefined {
  if (!navRoute) return undefined;
  const actions = VERB_ACTIONS[method] ?? [];
  for (const action of actions) {
    const candidate = `${navRoute}.${action}`;
    if (permissionPaths.has(candidate)) return candidate;
  }
  if (permissionPaths.has(navRoute)) return navRoute;
  return actions[0] ? `${navRoute}.${actions[0]}` : undefined;
}

/** Correlate in-scope navigable nav nodes with their registry views + resolved permissions. PURE. */
export function correlateRoutes(
  nodes: readonly NavNode[],
  keyOf: ReadonlyMap<string, string>,
  registryKeys: ReadonlySet<string>,
  permissionPaths: ReadonlySet<string>,
  prefix: string,
): DiscoveredNavRoute[] {
  const out: DiscoveredNavRoute[] = [];
  for (const node of nodes) {
    const componentKey = keyOf.get(node.id);
    if (!componentKey || !inScope(componentKey, prefix) || !node.route) continue;

    const views: ImplicitView[] = ['list'];
    for (const suffix of IMPLICIT_VIEW_SUFFIXES) {
      if (registryKeys.has(`${componentKey}.${suffix}`)) views.push(suffix);
    }

    const route: DiscoveredNavRoute = {
      componentKey,
      baseRoute: node.route,
      navKind: node.kind,
      views,
    };
    if (node.label) route.label = node.label;
    const permission = resolvePagePermission(componentKey, permissionPaths);
    if (permission) route.permission = permission;
    const parentKey = node.parentId ? keyOf.get(node.parentId) : undefined;
    if (parentKey) route.parentComponentKey = parentKey;
    out.push(route);
  }
  out.sort((a, b) => (a.componentKey < b.componentKey ? -1 : a.componentKey > b.componentKey ? 1 : 0));
  return out;
}

/** Filter parsed endpoints to scope + resolve their permissions. Deterministically ordered. PURE. */
export function correlateEndpoints(
  parsed: readonly ParsedEndpoint[],
  permissionPaths: ReadonlySet<string>,
  prefix: string,
): DiscoveredEndpoint[] {
  const out: DiscoveredEndpoint[] = [];
  for (const e of parsed) {
    if (!e.navRoute || !inScope(e.navRoute, prefix)) continue;
    const endpoint: DiscoveredEndpoint = { method: e.method, route: e.route };
    if (e.controller) endpoint.controller = e.controller;
    endpoint.navRoute = e.navRoute;
    const permission = resolveEndpointPermission(e.navRoute, e.method, permissionPaths);
    if (permission) endpoint.permission = permission;
    if (e.okStatus !== undefined) endpoint.okStatus = e.okStatus;
    if (e.allowAnonymous) endpoint.allowAnonymous = true;
    out.push(endpoint);
  }
  out.sort((a, b) => {
    const ka = `${a.method}:${a.route}`;
    const kb = `${b.method}:${b.route}`;
    return ka < kb ? -1 : ka > kb ? 1 : 0;
  });
  return out;
}

// ── I/O ─────────────────────────────────────────────────────────────────────

export interface DiscoverOptions {
  /** API project dir (controllers). */
  apiDir: string;
  /** Web app dir (componentRegistry under src/extensions). */
  webDir: string;
  /** Scope: `Application[/Module[/Section[/Resource]]]`. */
  path: string;
  connection: ParsedConnection;
  /** Authoritative role set (e.g. from provisioning). Default: roles that hold ≥1 grant. */
  rolesOverride?: string[];
  sqlcmdPath?: string;
  /** Parse + include the API axis (`endpoints[]`). */
  includeApi: boolean;
}

export interface DiscoverOutcome {
  result: DiscoveryResult;
  warnings: string[];
}

/** Read every generated registry file under web/src/extensions and collect component keys. */
async function readRegistryKeys(webDir: string): Promise<{ keys: Set<string>; pageKeysOnly: number }> {
  if (!webDir) return { keys: new Set(), pageKeysOnly: 0 };
  const files = await findFiles('**/extensions/**/*.{ts,tsx}', { cwd: webDir });
  const keys = new Set<string>();
  let pageKeysOnly = 0;
  for (const file of files) {
    const parsed = parseRegistry(await readText(file));
    for (const k of parsed.keys) keys.add(k);
    pageKeysOnly += parsed.pageKeysOnly;
  }
  return { keys, pageKeysOnly };
}

/** Live discovery: SQL skeleton + registry views + controllers, scoped + reconciled. */
export async function discover(opts: DiscoverOptions): Promise<DiscoverOutcome> {
  const warnings: string[] = [];
  const prefix = pathToPrefix(opts.path);
  const application = prefix.split('.')[0] || prefix;

  // SQL (synchronous — execFileSync under the hood).
  const navNodes = discoverNav(opts.connection, { sqlcmdPath: opts.sqlcmdPath });
  const permissions = discoverPermissions(opts.connection, { sqlcmdPath: opts.sqlcmdPath });
  const roleGrants = discoverRolePermissions(opts.connection, { sqlcmdPath: opts.sqlcmdPath });

  const keyOf = buildComponentKeys(navNodes);
  const permissionPaths = new Set(permissions.map((p) => p.path));

  const { keys: registryKeys, pageKeysOnly } = await readRegistryKeys(opts.webDir);
  if (registryKeys.size === 0 && pageKeysOnly > 0) {
    warnings.push(
      `componentRegistry uses the PAGE_KEYS enum form (${pageKeysOnly} entries) — suffix views are not resolvable; routes are limited to nav-DB list pages.`,
    );
  }

  const routes = correlateRoutes(navNodes, keyOf, registryKeys, permissionPaths, prefix);
  if (routes.length === 0) {
    warnings.push(`No navigable routes found under path "${opts.path}" (prefix "${prefix}").`);
  }

  const endpoints = opts.includeApi
    ? correlateEndpoints(await discoverEndpoints(opts.apiDir), permissionPaths, prefix)
    : [];

  // Roles with ≥1 grant (core.auth_RolePermissions is an inner join, so zero-grant roles
  // are absent — pass rolesOverride from provisioning to test those too).
  const roleNames = opts.rolesOverride ?? [...new Set(roleGrants.map((g) => g.roleName))];
  const roles = roleNames.includes('anonymous') ? [...roleNames] : [...roleNames, 'anonymous'];
  const grantsByRole = groupGrantsByRole(roleGrants);

  const signature = computeSignature({
    nav: JSON.stringify(navNodes),
    rbac: JSON.stringify(roleGrants),
    registry: JSON.stringify([...registryKeys].sort()),
  });

  return {
    result: { application, path: opts.path, routes, endpoints, rbac: { roles, grantsByRole }, signature },
    warnings,
  };
}
