/**
 * Codegen — `spec.ui_routes[]` artifacts (B.12-2).
 *
 * For each UI-route in the spec, synthesise:
 *
 *   - A standalone `routes/<id>UiRouter.ts` (or `.tsx` for react-ssr) file
 *     that exports a `create<Id>UiRouter` factory returning an Express
 *     `Router` instance.
 *
 *   - An auto-managed line-block for the `ui-routes-imports` slot in
 *     plugin.ts (factory imports + `Router` import).
 *
 *   - An auto-managed line-block for the `ui-routes-init` slot in
 *     plugin.ts (factory instantiation, `ctx.routes.register(...)` mount,
 *     `ctx.uiRoutes.register(...)` descriptor wiring, disposer push).
 *
 *   - Conditional peerDependency additions:
 *       - `@omadia/plugin-ui-helpers` whenever ≥ 1 ui_route exists
 *       - `react` + `react-dom` when ≥ 1 route uses render_mode='react-ssr'
 *         (NOT YET — B.12-2b will land that branch)
 *
 * Render-mode handling in this commit:
 *
 *   - `library`         — fully implemented (list-card + kpi-tiles)
 *   - `free-form-html`  — fully implemented (operator-provided
 *                         `ui-<id>-render` slot interpolated into the
 *                         renderRoute callback body)
 *   - `react-ssr`       — throws CodegenError(NotImplemented). Schema +
 *                         lint reserve the mode; B.12-2b adds the
 *                         renderReactRoute helper + Tailwind-PostCSS-Build.
 */

import { CodegenError } from './codegen.js';
import type { AgentSpec } from './agentSpec.js';
import type { UiRoute } from './uiRouteSchema.js';

export interface UiRouteArtifacts {
  /** Body for the `ui-routes-imports` slot in plugin.ts. Empty when
   *  spec.ui_routes is empty. */
  readonly imports: string;
  /** Body for the `ui-routes-init` slot in plugin.ts. Empty when
   *  spec.ui_routes is empty. */
  readonly init: string;
  /** Files to emit alongside the boilerplate output. Keys are plugin-root-
   *  relative (e.g. `routes/inboxUiRouter.ts`). */
  readonly files: ReadonlyMap<string, Buffer>;
  /** peerDependencies to merge into package.json. Empty when ui_routes
   *  is empty. */
  readonly peerDependencies: Readonly<Record<string, string>>;
  /** True when ≥1 ui_route uses render_mode='react-ssr'. Codegen uses
   *  this to flip jsx: 'react-jsx' in the plugin's tsconfig.json. */
  readonly hasReactSsr: boolean;
}

const EMPTY: UiRouteArtifacts = {
  imports: '',
  init: '',
  files: new Map(),
  peerDependencies: {},
  hasReactSsr: false,
};

/**
 * Top-level entry. Pass `spec`; receive everything codegen needs to
 * splice ui_routes into the output bundle.
 */
export function buildUiRouteArtifacts(
  spec: AgentSpec,
  slots: Readonly<Record<string, string>>,
): UiRouteArtifacts {
  if (spec.ui_routes.length === 0) return EMPTY;

  const files = new Map<string, Buffer>();
  const hasReactSsr = spec.ui_routes.some((r) => r.render_mode === 'react-ssr');

  // plugin.ts-level imports: ONLY the factory functions are needed here.
  // Router itself lives in the routes/* files (it's both type + value
  // there). Earlier versions of this codegen injected `import { Router }
  // from 'express';` at plugin.ts level, which tsc flagged as TS6133
  // (Router declared but never read) — plugin.ts doesn't use Router
  // directly, the factories abstract it.
  const importLines: string[] = [];
  const initLines: string[] = [
    '// Auto-generated by codegen from spec.ui_routes — do not edit by hand.',
    'const __runTool = async (id: string, args?: unknown): Promise<unknown> => {',
    '  const tool = toolkit.tools.find((t) => t.id === id);',
    '  if (!tool) {',
    "    throw new Error('ui-route data-binding: tool ' + id + ' not found in toolkit');",
    '  }',
    '  return tool.run((args ?? {}) as never);',
    '};',
    '',
  ];

  for (const route of spec.ui_routes) {
    const factoryName = factoryNameFor(route.id);

    let routeFileExt: 'ts' | 'tsx';
    switch (route.render_mode) {
      case 'library': {
        const fileContent = renderLibraryFile(route);
        files.set(`routes/${camelize(route.id)}UiRouter.ts`, Buffer.from(fileContent, 'utf-8'));
        routeFileExt = 'ts';
        break;
      }
      case 'free-form-html': {
        const fileContent = renderFreeFormFile(route, slots);
        files.set(`routes/${camelize(route.id)}UiRouter.ts`, Buffer.from(fileContent, 'utf-8'));
        routeFileExt = 'ts';
        break;
      }
      case 'react-ssr': {
        const { routerSrc, componentSrc } = renderReactSsrFiles(route, slots);
        files.set(`routes/${camelize(route.id)}UiRouter.tsx`, Buffer.from(routerSrc, 'utf-8'));
        files.set(`components/${camelize(route.id)}Page.tsx`, Buffer.from(componentSrc, 'utf-8'));
        routeFileExt = 'tsx';
        break;
      }
      default: {
        const _exhaustive: never = route.render_mode;
        throw new CodegenError([
          {
            code: 'spec_validation',
            detail: `unknown render_mode '${String(_exhaustive)}' for route '${route.id}'`,
          },
        ]);
      }
    }

    // The runtime import always references `.js` regardless of source extension;
    // tsc with NodeNext resolves both `.ts` and `.tsx` to `.js` after build.
    importLines.push(
      `import { ${factoryName} } from './routes/${camelize(route.id)}UiRouter.js';`,
    );
    void routeFileExt;

    initLines.push(
      `// ui_route: ${route.id} (mode=${route.render_mode}, path=${route.path})`,
      `const __uiRoute_${camelize(route.id)} = ${factoryName}({ runTool: __runTool, pluginId: AGENT_ID });`,
      `__uiRouteDisposers.push(`,
      // Strip the `@scope/` prefix so the mount path matches the URL
      // convention used by channel-teams' Hub + Tab-Config (uiRouter.ts:56,
      // `pluginId.replace(/^@[^/]+\\//, '')`). Without this, scoped plugin
      // ids like `@omadia/agent-foo` mount at `/p/@omadia/agent-foo/...`
      // while consumers GET `/p/agent-foo/...` → 404.
      `  ctx.routes.register('/p/' + AGENT_ID.replace(/^@[^/]+\\//, ''), __uiRoute_${camelize(route.id)}),`,
      `);`,
      `__uiRouteDisposers.push(`,
      `  ctx.uiRoutes.register({`,
      `    routeId: ${JSON.stringify(route.id)},`,
      `    path: ${JSON.stringify(route.path)},`,
      `    title: ${JSON.stringify(route.tab_label)},`,
      `  }),`,
      `);`,
      '',
    );
  }

  // Trim trailing blank line in init body
  while (initLines.length > 0 && initLines[initLines.length - 1] === '') {
    initLines.pop();
  }

  // peerDependencies — every ui_route needs plugin-ui-helpers. React +
  // react-dom are added only when ≥1 route uses react-ssr (the
  // peerDependenciesMeta on plugin-ui-helpers marks them optional, so
  // library-/free-form-html-only plugins don't carry the React surface).
  const peerDependencies: Record<string, string> = {
    '@omadia/plugin-ui-helpers': '*',
  };
  if (hasReactSsr) {
    peerDependencies['react'] = '^18.3.1';
    peerDependencies['react-dom'] = '^18.3.1';
  }

  return {
    imports: importLines.join('\n'),
    init: initLines.join('\n'),
    files,
    peerDependencies,
    hasReactSsr,
  };
}

// --- File-content generators per render-mode ------------------------------

function renderLibraryFile(route: UiRoute): string {
  const factoryName = factoryNameFor(route.id);
  const interfaceName = `${pascalize(route.id)}UiRouterOptions`;
  const template = route.ui_template ?? 'list-card';
  const dataBinding = route.data_binding;
  const toolCallArgs = dataBinding?.args
    ? JSON.stringify(dataBinding.args)
    : '{}';

  const renderImport =
    template === 'kpi-tiles' ? 'renderKpiTiles' : 'renderListCard';

  let bodyContent: string;
  if (template === 'kpi-tiles') {
    // renderKpiTiles auto-unwraps `{ tiles: [...] }` / `{ kpis: [...] }`
    // wrappers — pass the raw tool output straight through.
    bodyContent = `body: renderKpiTiles({
        tiles: result,
        fetchError,
      })`;
  } else {
    // list-card — emit item_template literally so the helper interpolates
    // at render-time against each item. renderListCard auto-unwraps
    // wrapper-objects (e.g. `{ prs: [...] }`) and auto-promotes bare-
    // identifier template values (e.g. `"title"` → `"\${item.title}"`).
    const itemTemplate = route.item_template ?? { title: '${item.title}' };
    bodyContent = `body: renderListCard({
        items: result,
        itemTemplate: ${JSON.stringify(itemTemplate, null, 2).replace(/\n/g, '\n        ')},
        fetchError,
      })`;
  }

  const toolId = dataBinding?.tool_id ?? '';
  const dataFetchBlock = toolId
    ? `let result: unknown = [];
    let fetchError: string | null = null;
    try {
      result = await opts.runTool(${JSON.stringify(toolId)}, ${toolCallArgs});
    } catch (err) {
      fetchError = err instanceof Error ? err.message : String(err);
    }`
    : `const result: unknown = [];
    const fetchError: string | null = null;`;

  return `/**
 * Auto-generated by codegen from spec.ui_routes['${route.id}'] (B.12-2).
 * DO NOT EDIT BY HAND — re-run codegen if you need to change the route.
 *
 * render_mode: library
 * ui_template: ${template}
 * path:        ${route.path}
 * tab_label:   ${route.tab_label}
 */

import { Router } from 'express';
import { renderRoute, htmlDoc, ${renderImport} } from '@omadia/plugin-ui-helpers';

export type ToolRunner = (id: string, args?: unknown) => Promise<unknown>;

export interface ${interfaceName} {
  readonly runTool: ToolRunner;
  readonly pluginId: string;
}

export function ${factoryName}(opts: ${interfaceName}): Router {
  // Reference opts so tsc's noUnusedParameters is satisfied even when
  // the route has no data_binding (= no opts.runTool call). pluginId is
  // also part of the contract but only consumed by react-ssr-interactive
  // routes; the void here keeps the signature stable across all
  // library-mode variants without forcing a per-shape conditional.
  void opts;
  const router = Router();
  router.get(${JSON.stringify(route.path)}, renderRoute(async () => {
    ${dataFetchBlock}
    return htmlDoc({
      title: ${JSON.stringify(route.page_title)},
      refreshSeconds: ${String(route.refresh_seconds)},
      ${bodyContent},
    });
  }));
  return router;
}
`;
}

function renderReactSsrFiles(
  route: UiRoute,
  slots: Readonly<Record<string, string>>,
): { routerSrc: string; componentSrc: string } {
  const factoryName = factoryNameFor(route.id);
  const interfaceName = `${pascalize(route.id)}UiRouterOptions`;
  const componentName = `${pascalize(route.id)}Page`;
  const slotKey = `ui-${route.id}-component`;
  const componentSlot = slots[slotKey];
  // B.13 — interactive=true unlocks client-side hydration. The lint-pass
  // gates this to react-ssr-mode (other modes throw a CodegenIssue).
  const hydratable = route.interactive === true;
  if (componentSlot === undefined || componentSlot.trim().length === 0) {
    // Lint catches this earlier (ui_route_react_ssr_missing_component_slot),
    // but defending here keeps the codegen-direct-call path honest.
    throw new CodegenError([
      {
        code: 'missing_required_slot',
        detail:
          `ui_routes['${route.id}'] render_mode='react-ssr' requires slot ` +
          `'${slotKey}' to be filled with a default-exported React Component.`,
      },
    ]);
  }

  const dataBinding = route.data_binding;
  const toolId = dataBinding?.tool_id ?? '';
  const toolCallArgs = dataBinding?.args ? JSON.stringify(dataBinding.args) : '{}';

  const dataFetchBlock = toolId
    ? `let data: unknown = [];
    let fetchError: string | null = null;
    try {
      data = await opts.runTool(${JSON.stringify(toolId)}, ${toolCallArgs});
    } catch (err) {
      fetchError = err instanceof Error ? err.message : String(err);
    }`
    : `const data: unknown = [];
    const fetchError: string | null = null;`;

  const hydrationBlock = hydratable
    ? `,
        hydration: {
          pageId: ${JSON.stringify(route.id)},
          componentUrl: '/p/' + opts.pluginId + '/static/components/${camelize(route.id)}Page.js',
        }`
    : '';

  const staticMountImports = hydratable
    ? `import express from 'express';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
`
    : '';

  const staticMountBody = hydratable
    ? `
  // B.13 — static-serve the compiled component module so the browser-side
  // hydration script can import the SAME default-export the server SSR'd.
  // import.meta.url-derived path resolves to dist/routes/ at runtime; ../
  // walks up to dist/, then /components is the tsc-output dir.
  const __here = path.dirname(fileURLToPath(import.meta.url));
  const __componentsDir = path.resolve(__here, '..', 'components');
  router.use(
    '/static/components',
    express.static(__componentsDir, {
      setHeaders: (res) => {
        res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
      },
    }),
  );
`
    : '';

  const routerSrc = `/**
 * Auto-generated by codegen from spec.ui_routes['${route.id}'] (B.12-4 / B.13).
 * DO NOT EDIT BY HAND — re-run codegen if you need to change the route.
 *
 * render_mode: react-ssr
 * interactive: ${String(hydratable)}
 * path:        ${route.path}
 * tab_label:   ${route.tab_label}
 *
 * The accompanying components/${camelize(route.id)}Page.tsx holds the
 * operator-authored Component body (slot '${slotKey}').${
    hydratable
      ? `\n * Hydratable: the SSR HTML carries an importmap (esm.sh React) +\n * a module-script that imports the component from\n * /p/<id>/static/components/${camelize(route.id)}Page.js (served below).`
      : ''
  }
 */

import { Router } from 'express';
${staticMountImports}import { renderReactRoute } from '@omadia/plugin-ui-helpers';
import ${componentName} from '../components/${camelize(route.id)}Page.js';

export type ToolRunner = (id: string, args?: unknown) => Promise<unknown>;

export interface ${interfaceName} {
  readonly runTool: ToolRunner;
  readonly pluginId: string;
}

export function ${factoryName}(opts: ${interfaceName}): Router {
  // Reference opts so tsc's noUnusedParameters is satisfied across all
  // react-ssr shape variants (interactive + non-interactive, with and
  // without data_binding). The hydratable branch references
  // opts.pluginId in the componentUrl; the non-interactive + no-binding
  // branch otherwise wouldn't touch opts at all.
  void opts;
  const router = Router();
${staticMountBody}  router.get(${JSON.stringify(route.path)}, async (req, res, next) => {
    try {
      ${dataFetchBlock}
      const handler = renderReactRoute(${componentName}, {
        props: { data, fetchError },
        pageTitle: ${JSON.stringify(route.page_title)},
        refreshSeconds: ${String(route.refresh_seconds)}${hydrationBlock},
      });
      await handler({ req, res, params: req.params, query: req.query });
    } catch (err) {
      next(err);
    }
  });
  return router;
}
`;

  // Component file: operator-authored TSX inlined verbatim. Default-export
  // contract is enforced by the imports above (default-import only works
  // when the slot exports default). NO automatic React import — jsx-runtime
  // (`jsx: 'react-jsx'` from mergeTsconfigForReactSsr) handles JSX implicitly,
  // so prepending `import * as React from 'react'` would trip `noUnusedLocals`
  // (TS6133) when the operator doesn't reference the React namespace
  // directly, AND collide with an operator-supplied React import (TS2300
  // Duplicate identifier). If the operator wants to use `React.<Type>` they
  // import it themselves — standard React+TS practice.
  const componentSrc = `/**
 * Auto-generated by codegen from spec.ui_routes['${route.id}'] (B.12-4).
 * Body comes from the operator-filled slot '${slotKey}' — re-run codegen
 * after editing via fill_slot. The default-export contract MUST be kept;
 * the Express-Glue file imports this module as a default-import.
 *
 * Props contract (passed by routes/${camelize(route.id)}UiRouter.tsx):
 *   { data: unknown; fetchError: string | null }
 * If you change the Component signature, the router's renderReactRoute call
 * will fail tsc. Keep the shape stable or update both sides together.
 */

${componentSlot.trim()}
`;

  return { routerSrc, componentSrc };
}

function renderFreeFormFile(
  route: UiRoute,
  slots: Readonly<Record<string, string>>,
): string {
  const factoryName = factoryNameFor(route.id);
  const interfaceName = `${pascalize(route.id)}UiRouterOptions`;
  const slotKey = `ui-${route.id}-render`;
  const renderSlot = slots[slotKey];
  if (renderSlot === undefined || renderSlot.trim().length === 0) {
    // lintSpec already catches this earlier; defensive throw here keeps
    // the failure-mode predictable even when codegen is called directly.
    throw new CodegenError([
      {
        code: 'missing_required_slot',
        detail:
          `ui_routes['${route.id}'] render_mode='free-form-html' requires slot ` +
          `'${slotKey}' to be filled.`,
      },
    ]);
  }

  // Re-indent the slot body so it sits cleanly inside the renderRoute
  // callback. The slot is expected to RETURN an HtmlFragment (e.g.
  // `return htmlDoc({...})`).
  const indented = renderSlot
    .split('\n')
    .map((line) => (line.length > 0 ? `    ${line}` : line))
    .join('\n');

  return `/**
 * Auto-generated by codegen from spec.ui_routes['${route.id}'] (B.12-2).
 * The render body comes from the LLM-filled slot '${slotKey}' — see spec.slots.
 *
 * render_mode: free-form-html
 * path:        ${route.path}
 * tab_label:   ${route.tab_label}
 */

import { Router } from 'express';
import { renderRoute, htmlDoc, html, safe } from '@omadia/plugin-ui-helpers';
import type { HtmlFragment } from '@omadia/plugin-ui-helpers';

export type ToolRunner = (id: string, args?: unknown) => Promise<unknown>;

export interface ${interfaceName} {
  readonly runTool: ToolRunner;
  readonly pluginId: string;
}

export function ${factoryName}(opts: ${interfaceName}): Router {
  // Reference unused imports so tsc doesn't strip them when the LLM slot
  // happens not to use them.
  void htmlDoc; void html; void safe; void opts;
  const router = Router();
  router.get(${JSON.stringify(route.path)}, renderRoute(async () => {
    const __slotBody: HtmlFragment = await (async () => {
${indented}
    })();
    return __slotBody;
  }));
  return router;
}
`;
}

// --- Name helpers ---------------------------------------------------------

function camelize(id: string): string {
  return id
    .split('-')
    .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
    .join('');
}

function pascalize(id: string): string {
  return id
    .split('-')
    .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
    .join('');
}

function factoryNameFor(id: string): string {
  return `create${pascalize(id)}UiRouter`;
}
