#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..', '..');

const OVERRIDES_DIR = path.resolve(repoRoot, 'ee/docs/api-registry');

// Dual-edition: generate one registry per edition from its OpenAPI spec.
// - CE registry feeds the CE `meta/mcp-registry` endpoint + local connector.
// - EE registry additionally powers the in-app chat assistant.
// Set MCP_REGISTRY_EDITION=ce|ee to generate just one.
const TARGETS = [
  {
    edition: 'ce',
    specPath: path.resolve(repoRoot, 'sdk/docs/openapi/alga-openapi.ce.json'),
    outputPath: path.resolve(repoRoot, 'server/src/lib/mcp/registry.generated.ts'),
    importPath: '@alga-psa/agent-tooling/registry/schema',
  },
  {
    edition: 'ee',
    specPath: path.resolve(repoRoot, 'sdk/docs/openapi/alga-openapi.ee.json'),
    outputPath: path.resolve(repoRoot, 'ee/server/src/chat/registry/apiRegistry.generated.ts'),
    importPath: './apiRegistry.schema',
  },
];

const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'];
const PLACEHOLDER_DESCRIPTION =
  'This operation was generated automatically from the route inventory. Replace with canonical OpenAPI metadata.';

function singularize(segment) {
  if (segment.endsWith('ies')) {
    return `${segment.slice(0, -3)}y`;
  }
  if (segment.endsWith('ses')) {
    return segment.slice(0, -2);
  }
  if (segment.endsWith('s') && !segment.endsWith('ss')) {
    return segment.slice(0, -1);
  }
  return segment;
}

function humanizeSegment(segment) {
  return segment
    .replace(/[_-]+/g, ' ')
    .trim()
    .split(/\s+/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

function deriveResourceSegments(pathName) {
  const rawSegments = pathName.split('/').filter(Boolean);
  const apiIndex = rawSegments.findIndex((segment) => segment === 'api');
  const segments = apiIndex === -1 ? rawSegments : rawSegments.slice(apiIndex + 1);
  if (segments[0] && /^v\d+$/i.test(segments[0])) {
    segments.shift();
  }
  return segments;
}

function deriveFallbackNames(method, pathName) {
  const segments = deriveResourceSegments(pathName);
  const nonParamSegments = segments.filter((segment) => !segment.startsWith('{'));
  const pathParams = segments.filter((segment) => segment.startsWith('{') && segment.endsWith('}'));
  const primary = nonParamSegments[0] ?? 'resource';
  const primaryLabel = humanizeSegment(singularize(primary));
  const trailing = nonParamSegments.slice(1).map((segment) => humanizeSegment(singularize(segment)));
  const tailLabel = trailing.join(' ');

  if (method === 'get' && pathParams.length === 1 && nonParamSegments.length === 1) {
    return {
      displayName: `Get ${primaryLabel}`,
      summary: `Get ${primaryLabel.toLowerCase()} by ID`,
    };
  }

  if (method === 'put' && pathParams.length === 1 && nonParamSegments.length === 1) {
    return {
      displayName: `Update ${primaryLabel}`,
      summary: `Update ${primaryLabel.toLowerCase()} by ID`,
    };
  }

  if (method === 'patch' && pathParams.length === 1 && nonParamSegments.length === 1) {
    return {
      displayName: `Update ${primaryLabel}`,
      summary: `Update ${primaryLabel.toLowerCase()} fields by ID`,
    };
  }

  if (method === 'delete' && pathParams.length === 1 && nonParamSegments.length === 1) {
    return {
      displayName: `Delete ${primaryLabel}`,
      summary: `Delete ${primaryLabel.toLowerCase()} by ID`,
    };
  }

  if (method === 'get' && pathParams.length > 0 && tailLabel) {
    return {
      displayName: `List ${primaryLabel} ${tailLabel}`,
      summary: `List ${tailLabel.toLowerCase()} for a ${primaryLabel.toLowerCase()}`,
    };
  }

  return null;
}

function inferPathParameters(pathName, existingParameters) {
  const existingPathParamNames = new Set(
    (existingParameters ?? [])
      .filter((param) => param.in === 'path')
      .map((param) => param.name),
  );

  const inferred = [];
  for (const match of pathName.matchAll(/\{([^}]+)\}/g)) {
    const name = match[1];
    if (!name || existingPathParamNames.has(name)) {
      continue;
    }

    inferred.push({
      name,
      in: 'path',
      required: true,
      description:
        name === 'id'
          ? 'Resource identifier.'
          : `${humanizeSegment(name)} path parameter.`,
      schema: {
        type: 'string',
        ...(name === 'id' ? { format: 'uuid' } : {}),
      },
    });
  }

  return inferred;
}

function loadSpec(specPath) {
  if (!fs.existsSync(specPath)) {
    throw new Error(`OpenAPI spec not found at ${specPath}. Run npm run openapi:generate first.`);
  }
  const content = fs.readFileSync(specPath, 'utf-8');
  return JSON.parse(content);
}

function resolveRef(spec, maybeRef) {
  if (!maybeRef || typeof maybeRef !== 'object' || !('$ref' in maybeRef)) {
    return maybeRef;
  }

  const ref = maybeRef.$ref;
  if (typeof ref !== 'string' || !ref.startsWith('#/')) {
    throw new Error(`Unsupported $ref format: ${ref}`);
  }

  const parts = ref.slice(2).split('/');
  let current = spec;
  for (const part of parts) {
    current = current?.[part];
    if (current === undefined) {
      throw new Error(`Failed to resolve $ref: ${ref}`);
    }
  }
  return current;
}

function cloneSchema(schema) {
  if (schema === undefined) return undefined;
  return JSON.parse(JSON.stringify(schema));
}

function collectParameters(spec, pathItem, operation) {
  const params = [
    ...(Array.isArray(pathItem?.parameters) ? pathItem.parameters : []),
    ...(Array.isArray(operation?.parameters) ? operation.parameters : []),
  ];

  const seen = new Set();
  const collected = [];

  for (const paramRef of params) {
    if (!paramRef) continue;
    const param = resolveRef(spec, paramRef);
    if (!param || typeof param !== 'object') continue;
    if ('in' in param === false || 'name' in param === false) continue;
    if (param.in !== 'query' && param.in !== 'path' && param.in !== 'header') continue;
    const key = `${param.in}:${param.name}`;
    if (seen.has(key)) continue;
    seen.add(key);
    collected.push({
      name: param.name,
      in: param.in,
      required: Boolean(param.required),
      description: param.description,
      schema: cloneSchema(resolveRef(spec, param.schema ?? {})),
    });
  }

  return collected;
}

function extractRequestBody(spec, operation) {
  if (!operation || !operation.requestBody) return { schema: undefined, example: undefined };
  const requestBody = resolveRef(spec, operation.requestBody);
  if (!requestBody || typeof requestBody !== 'object' || !requestBody.content) {
    return { schema: undefined, example: undefined };
  }

  const jsonContent = requestBody.content['application/json'];
  if (!jsonContent) return { schema: undefined, example: undefined };

  const schema = jsonContent.schema ? resolveRef(spec, jsonContent.schema) : undefined;
  const example = jsonContent.example ?? Object.values(jsonContent.examples ?? {})[0]?.value;

  return {
    schema: cloneSchema(schema),
    example: cloneSchema(example),
  };
}

function extractResponseBody(spec, operation) {
  const responses = operation?.responses ?? {};
  const statuses = Object.keys(responses).filter((code) => code.startsWith('2')).sort();
  if (statuses.length === 0) return undefined;
  const status = statuses[0];
  const response = resolveRef(spec, responses[status]);
  if (!response || typeof response !== 'object') return undefined;
  if (!response.content) return undefined;
  const jsonContent = response.content['application/json'];
  if (!jsonContent) return undefined;
  const schema = jsonContent.schema ? resolveRef(spec, jsonContent.schema) : undefined;
  return cloneSchema(schema);
}

function loadOverrides(dir) {
  const byId = new Map();
  const byMethodPath = new Map();

  if (!fs.existsSync(dir)) {
    return { byId, byMethodPath };
  }

  const files = fs.readdirSync(dir).filter((file) => file.endsWith('.json'));
  for (const file of files) {
    const filePath = path.join(dir, file);
    const content = fs.readFileSync(filePath, 'utf-8');
    let parsed;
    try {
      parsed = JSON.parse(content);
    } catch (error) {
      throw new Error(`Failed to parse override file ${filePath}: ${error.message}`);
    }

    const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
    for (const entry of entries) {
      const match = entry?.match ?? {};
      if (match.id) {
        const list = byId.get(match.id) ?? [];
        list.push(entry);
        byId.set(match.id, list);
      }
      if (match.method && match.path) {
        const key = `${String(match.method).toLowerCase()} ${match.path}`;
        const list = byMethodPath.get(key) ?? [];
        list.push(entry);
        byMethodPath.set(key, list);
      }
    }
  }

  return { byId, byMethodPath };
}

function applyOverrides(entry, overrides) {
  const matches = [];
  if (overrides.byId.has(entry.id)) {
    matches.push(...(overrides.byId.get(entry.id) ?? []));
  }
  const key = `${entry.method} ${entry.path}`;
  if (overrides.byMethodPath.has(key)) {
    matches.push(...(overrides.byMethodPath.get(key) ?? []));
  }

  for (const override of matches) {
    const metadata = override?.metadata ?? {};
    if (metadata.displayName) entry.displayName = metadata.displayName;
    if (metadata.summary) entry.summary = metadata.summary;
    if (metadata.description) entry.description = metadata.description;
    if (metadata.rbacResource) entry.rbacResource = metadata.rbacResource;
    if (typeof metadata.approvalRequired === 'boolean') {
      entry.approvalRequired = metadata.approvalRequired;
    }
    if (Array.isArray(metadata.playbooks)) entry.playbooks = metadata.playbooks;
    if (Array.isArray(metadata.examples)) {
      entry.examples = metadata.examples.map((example) => normalizeExample(example));
    }
    if (Array.isArray(metadata.parameters)) entry.parameters = metadata.parameters;
    if (metadata.requestBodySchema !== undefined) entry.requestBodySchema = metadata.requestBodySchema;
    if (metadata.responseBodySchema !== undefined) entry.responseBodySchema = metadata.responseBodySchema;
  }
}

function normalizeExample(example) {
  if (!example || typeof example !== 'object') {
    return example;
  }

  const normalized = { ...example };
  const request = normalized.request;
  if (!request || typeof request !== 'object' || Array.isArray(request)) {
    return normalized;
  }

  if ('path' in request && !('params' in request)) {
    const { path, ...rest } = request;
    normalized.request = {
      ...rest,
      params: path,
    };
    return normalized;
  }

  normalized.request = { ...request };
  return normalized;
}

function createEntryId(method, pathName, operationId) {
  if (operationId) return operationId;
  return `${method}-${pathName.replace(/[{}]/g, '').replace(/[\\/]/g, '_').replace(/[^a-zA-Z0-9_]/g, '')}`.toLowerCase();
}

function collectOperations(spec) {
  const entries = [];
  const paths = spec.paths ?? {};

  for (const [pathName, pathItem] of Object.entries(paths)) {
    if (!pathItem || typeof pathItem !== 'object') continue;

    for (const method of HTTP_METHODS) {
      const operation = pathItem[method];
      if (!operation || typeof operation !== 'object') continue;

      const id = createEntryId(method, pathName, operation.operationId);
      const parameters = collectParameters(spec, pathItem, operation);
      const fallback = deriveFallbackNames(method, pathName);
      const { schema: requestBodySchema, example: requestExample } = extractRequestBody(spec, operation);
      const responseBodySchema = extractResponseBody(spec, operation);
      const displayName = operation['x-chat-display-name']
        ?? operation.summary
        ?? fallback?.displayName
        ?? `${method.toUpperCase()} ${pathName}`;
      const summary = operation.summary
        ?? fallback?.summary
        ?? `${method.toUpperCase()} ${pathName}`;
      const description = operation.description;
      const normalizedDescription =
        description === PLACEHOLDER_DESCRIPTION && fallback ? undefined : description;

      const entry = {
        id,
        method,
        path: pathName,
        displayName,
        summary,
        description: normalizedDescription,
        tags: Array.isArray(operation.tags) ? operation.tags : [],
        rbacResource: operation['x-chat-rbac-resource'] ?? operation['x-rbac-resource'],
        approvalRequired: Boolean(operation['x-chat-approval-required']),
        parameters: [...parameters, ...inferPathParameters(pathName, parameters)],
        requestBodySchema,
        requestExample,
        responseBodySchema,
      };

      entries.push(entry);
    }
  }

  return entries;
}

function writeOutput(entries, outputPath, importPath) {
  const header = `/* eslint-disable */\n// AUTO-GENERATED FILE. DO NOT EDIT.\n// Generated by ee/scripts/generate-chat-registry.mjs\n`;
  // `import type`: the schema is used only for the annotation, so it is erased
  // at runtime — the CE registry can live in the server bundle without pulling
  // @alga-psa/agent-tooling into the runtime graph.
  const importLine = `import type { ChatApiRegistryEntry } from '${importPath}';\n\n`;
  const sanitized = entries.map((entry) => JSON.parse(JSON.stringify(entry)));
  const body = `export const chatApiRegistry: ChatApiRegistryEntry[] = ${JSON.stringify(sanitized, null, 2)};\n\nexport default chatApiRegistry;\n`;

  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
  fs.writeFileSync(outputPath, `${header}${importLine}${body}`);
}

function generateTarget(target, overrides) {
  const spec = loadSpec(target.specPath);
  const entries = collectOperations(spec);

  for (const entry of entries) {
    applyOverrides(entry, overrides);
  }

  writeOutput(entries, target.outputPath, target.importPath);
  console.log(
    `[${target.edition}] generated ${entries.length} entries -> ${path.relative(repoRoot, target.outputPath)}`,
  );
  return entries;
}

function main() {
  const overrides = loadOverrides(OVERRIDES_DIR);
  const only = process.env.MCP_REGISTRY_EDITION;
  const targets = only ? TARGETS.filter((t) => t.edition === only) : TARGETS;
  if (targets.length === 0) {
    throw new Error(`No registry targets matched MCP_REGISTRY_EDITION="${only}".`);
  }

  const byEdition = {};
  for (const target of targets) {
    byEdition[target.edition] = generateTarget(target, overrides);
  }

  // Invariant (T005): the CE surface must be a subset of EE — EE is a superset.
  if (byEdition.ce && byEdition.ee) {
    const eeIds = new Set(byEdition.ee.map((e) => e.id));
    const ceOnly = byEdition.ce.filter((e) => !eeIds.has(e.id)).map((e) => e.id);
    if (ceOnly.length > 0) {
      throw new Error(
        `CE registry has ${ceOnly.length} endpoint(s) absent from EE (EE must be a superset): ` +
          ceOnly.slice(0, 10).join(', '),
      );
    }
    console.log(`OK: CE (${byEdition.ce.length}) is a subset of EE (${byEdition.ee.length}).`);
  }
}

const arg1 = process.argv[1];
const arg1Url = arg1 ? pathToFileURL(path.resolve(arg1)).href : '';

if (arg1 && import.meta.url === arg1Url) {
  main();
} else {
  // Support running via `node ee/scripts/generate-chat-registry.mjs`
  const resolvedArg = arg1 ? path.resolve(arg1) : '';
  if (resolvedArg === __filename) {
    main();
  }
}
