#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { z } from 'zod';
import { GradialImageSchema } from '../types/image.ts';

// Shim React global so JSX-containing component files can be loaded.
// The compile-registry only extracts contract metadata (schemas, imageSlots)
// and never renders components, but importing .tsx files requires the JSX
// runtime to be resolvable. Next.js uses jsx: "preserve" which leaves React
// references in the transpiled output.
try {
  const React = await import('react');
  if (!globalThis.React) globalThis.React = React;
} catch { /* react not available — component files without JSX will still work */ }

const args = parseArgs(process.argv.slice(2));

try {
  const result = await compileRegistry(args);
  console.log(JSON.stringify({ success: true, ...result, errors: [] }, null, 2));
} catch (error) {
  console.error(JSON.stringify({ success: false, errors: [String(error?.message ?? error)] }, null, 2));
  process.exitCode = 1;
}

async function compileRegistry(options) {
  const required = ['components', 'layouts', 'output'];
  for (const key of required) {
    if (!options[key]) throw new Error(`missing --${key}`);
  }

  const components = await loadDefaultArray(options.components, 'component registry');
  const layouts = await loadDefaultArray(options.layouts, 'layout registry');
  const outDir = path.resolve(options.output);
  const schemaDir = path.join(outDir, 'schemas');
  await mkdir(schemaDir, { recursive: true });

  const seenComponents = new Set();
  const componentEntries = [];
  const schemaFiles = [];
  for (const [index, component] of components.entries()) {
    validateComponent(component, index, seenComponents);
    const schema = compileSchema(component.schema, component.name);
    const schemaFile = `schemas/${component.name}.schema.json`;
    const schemaPath = path.join(outDir, schemaFile);
    await writeFile(schemaPath, JSON.stringify(schema, null, 2) + '\n');
    schemaFiles.push(schemaPath);
    const renderModes = component.renderModes ?? component.render;
    const varyDimensions = component.varyDimensions ?? component.vary;
    const imageSlots = normalizeImageSlots(component.imageSlots);
    componentEntries.push({
      name: component.name,
      schemaFile,
      render: {
        static: Boolean(renderModes.canStatic ?? renderModes.static),
        ssr: Boolean(renderModes.canSSR ?? renderModes.ssr),
        clientIsland: Boolean(renderModes.canClientIsland ?? renderModes.clientIsland),
      },
      renderModes: {
        canStatic: Boolean(renderModes.canStatic ?? renderModes.static),
        canSSR: Boolean(renderModes.canSSR ?? renderModes.ssr),
        canClientIsland: Boolean(renderModes.canClientIsland ?? renderModes.clientIsland),
      },
      ...(component.defaultIslandMode ? { defaultIslandMode: component.defaultIslandMode } : {}),
      ...(varyDimensions?.length ? { vary: varyDimensions, varyDimensions } : {}),
      ...(Object.keys(imageSlots).length ? { imageSlots } : {}),
    });
  }

  const seenLayouts = new Set();
  const layoutEntries = layouts.map((layout, index) => validateLayout(layout, index, seenLayouts));

  // Post-compilation: detect nested block patterns in JSON schemas and
  // validate that referenced component names exist in the registry.
  const knownNames = new Set(componentEntries.map((c) => c.name));
  for (const entry of componentEntries) {
    const schemaPath = path.join(outDir, entry.schemaFile);
    const schemaRaw = await import('node:fs/promises').then((fs) => fs.readFile(schemaPath, 'utf-8'));
    const schema = JSON.parse(schemaRaw);
    const nestedBlocks = collectNestedBlockRefs(schema, knownNames, entry.name);
    if (nestedBlocks.length > 0) {
      entry.nestedBlocks = nestedBlocks;
    }
  }

  const registry = {
    manifestVersion: '1',
    generatedAt: new Date().toISOString(),
    components: componentEntries,
    layouts: layoutEntries,
  };
  const registryPath = path.join(outDir, 'registry.json');
  await writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n');
  return { registry: registryPath, schemas: schemaFiles };
}

async function loadDefaultArray(modulePath, label) {
  const mod = await import(pathToFileURL(path.resolve(modulePath)).href);
  const value = mod.default ?? mod.registry ?? mod.components ?? mod.layouts;
  if (!Array.isArray(value)) throw new Error(`${label} must export an array`);
  return value;
}

function validateComponent(component, index, seen) {
  if (!component || typeof component !== 'object') throw new Error(`components[${index}] must be an object`);
  if (!component.name) throw new Error(`components[${index}].name is required`);
  if (seen.has(component.name)) throw new Error(`duplicate component name ${component.name}`);
  seen.add(component.name);
  if (!component.schema) throw new Error(`components[${index}].schema is required`);
  validateZodSubset(component.schema, `components[${index}].schema`);
  validateImageSlotSchemaMatch(component, index);
  const renderModes = component.renderModes ?? component.render;
  if (!renderModes) throw new Error(`components[${index}].renderModes is required`);
  const enabled = Boolean(renderModes.canStatic ?? renderModes.static)
    || Boolean(renderModes.canSSR ?? renderModes.ssr)
    || Boolean(renderModes.canClientIsland ?? renderModes.clientIsland);
  if (!enabled) throw new Error(`components[${index}].renderModes must enable at least one mode`);
}

function validateImageSlotSchemaMatch(component, index) {
  const imageSlots = normalizeImageSlots(component.imageSlots);
  const imageFields = collectGradialImageFields(component.schema);
  for (const field of imageFields) {
    if (!Object.prototype.hasOwnProperty.call(imageSlots, field)) {
      throw new Error(`components[${index}].imageSlots.${field} is required because schema field ${field} uses GradialImageSchema`);
    }
  }
  for (const field of Object.keys(imageSlots)) {
    if (!imageFields.has(field)) {
      throw new Error(`components[${index}].schema.${field} must use GradialImageSchema because imageSlots.${field} is declared`);
    }
  }
}

function normalizeImageSlots(imageSlots) {
  if (imageSlots == null) return {};
  if (typeof imageSlots !== 'object' || Array.isArray(imageSlots)) {
    throw new Error('imageSlots must be an object');
  }
  const out = {};
  for (const [key, slot] of Object.entries(imageSlots)) {
    if (!key) throw new Error('imageSlots keys must be non-empty');
    if (!slot || typeof slot !== 'object' || Array.isArray(slot)) {
      throw new Error(`imageSlots.${key} must be an object`);
    }
    if (!Array.isArray(slot.outputs) || !slot.outputs.length) {
      throw new Error(`imageSlots.${key}.outputs must be a non-empty array`);
    }
    if (!Array.isArray(slot.formats) || !slot.formats.length) {
      throw new Error(`imageSlots.${key}.formats must be a non-empty array`);
    }
    if (typeof slot.sizes !== 'string' || !slot.sizes.trim()) {
      throw new Error(`imageSlots.${key}.sizes must be a non-empty string`);
    }
    out[key] = {
      outputs: slot.outputs.map((output, outputIndex) => normalizeSlotOutput(key, output, outputIndex)),
      formats: slot.formats.map((format, formatIndex) => {
        if (typeof format !== 'string' || !format.trim()) {
          throw new Error(`imageSlots.${key}.formats[${formatIndex}] must be a non-empty string`);
        }
        return format;
      }),
      sizes: slot.sizes,
    };
  }
  return out;
}

function normalizeSlotOutput(slotKey, output, outputIndex) {
  if (!output || typeof output !== 'object' || Array.isArray(output)) {
    throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}] must be an object`);
  }
  if (typeof output.aspectRatio !== 'string' || !output.aspectRatio.trim()) {
    throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].aspectRatio must be a non-empty string`);
  }
  if (!Array.isArray(output.widths) || !output.widths.length) {
    throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].widths must be a non-empty array`);
  }
  const widths = output.widths.map((width, widthIndex) => {
    if (!Number.isInteger(width) || width <= 0) {
      throw new Error(`imageSlots.${slotKey}.outputs[${outputIndex}].widths[${widthIndex}] must be a positive integer`);
    }
    return width;
  });
  return {
    aspectRatio: output.aspectRatio,
    widths,
    ...(typeof output.media === 'string' && output.media.trim() ? { media: output.media } : {}),
  };
}

function collectGradialImageFields(schema) {
  const out = new Set();

  collectGradialImageFieldsFromSchema(schema, '', out);

  return out;
}

function collectGradialImageFieldsFromSchema(schema, prefix, out) {
  if (!schema) {
    return;
  }

  if (prefix && isGradialImageSchema(schema)) {
    out.add(prefix);
    return;
  }

  const itemSchema = arrayItemSchema(schema);
  if (itemSchema) {
    collectGradialImageFieldsFromSchema(itemSchema, prefix, out);
    return;
  }

  const unionOptions = unionOptionSchemas(schema);
  if (unionOptions) {
    for (const option of unionOptions) {
      collectGradialImageFieldsFromSchema(option, prefix, out);
    }
    return;
  }

  const def = unwrapSchema(schema)?._def;
  const shape = typeof def?.shape === 'function' ? def.shape() : def?.shape;
  if (!shape || typeof shape !== 'object') {
    return;
  }

  for (const [field, fieldSchema] of Object.entries(shape)) {
    const nextPrefix = prefix ? `${prefix}.${field}` : field;
    collectGradialImageFieldsFromSchema(fieldSchema, nextPrefix, out);
  }
}

function unionOptionSchemas(schema) {
  const def = unwrapSchema(schema)?._def;
  if (!def) return null;
  if (Array.isArray(def.options)) return def.options;
  if (def.options instanceof Map) return Array.from(def.options.values());
  return null;
}

function arrayItemSchema(schema) {
  let current = schema;
  const seen = new Set();
  while (current && !seen.has(current)) {
    seen.add(current);
    const def = current?._def;
    if (!def) {
      // Zod 4 mini: check for ~standard array type
      if (current?.type === 'array' && current?.element) return current.element;
      return null;
    }
    if (def.typeName === 'ZodArray') return def.type;
    // Zod 4: array element might be in def.element
    if (def.type === 'array' && def.element) return def.element;
    current = def.innerType ?? def.schema;
  }
  return null;
}

function isGradialImageSchema(schema) {
  const unwrapped = unwrapSchema(schema);
  if (unwrapped === GradialImageSchema) return true;
  try {
    const compiled = z.toJSONSchema(unwrapped, {
      target: 'draft-2020-12',
      unrepresentable: 'throw',
    });
    const sources = compiled?.properties?.sources;
    const sourcesIsArray = sources?.type === 'array'
      || (Array.isArray(sources?.anyOf) && sources.anyOf.some((s) => s?.type === 'array'));
    return compiled?.type === 'object'
      && compiled?.properties?.$type?.const === 'gradial.image'
      && compiled?.properties?.fallback?.type === 'object'
      && sourcesIsArray;
  } catch {
    return false;
  }
}

function unwrapSchema(schema) {
  let current = schema;
  const seen = new Set();
  while (current && !seen.has(current)) {
    seen.add(current);
    if (current === GradialImageSchema) return current;
    const def = current._def;
    const next = def?.innerType ?? def?.schema;
    if (!next) break;
    current = next;
  }
  return current;
}

function validateLayout(layout, index, seen) {
  if (!layout || typeof layout !== 'object') throw new Error(`layouts[${index}] must be an object`);
  if (!layout.name) throw new Error(`layouts[${index}].name is required`);
  if (seen.has(layout.name)) throw new Error(`duplicate layout name ${layout.name}`);
  seen.add(layout.name);
  if (!Array.isArray(layout.slots)) throw new Error(`layouts[${index}].slots must be an array`);
  const slots = [];
  const seenSlots = new Set();
  for (const [slotIndex, slot] of layout.slots.entries()) {
    if (!slot?.name) throw new Error(`layouts[${index}].slots[${slotIndex}].name is required`);
    if (seenSlots.has(slot.name)) throw new Error(`duplicate slot name ${slot.name} in layout ${layout.name}`);
    seenSlots.add(slot.name);
    slots.push({ name: slot.name, required: Boolean(slot.required) });
  }
  return { name: layout.name, slots };
}

function compileSchema(schema, name) {
  const toJSONSchema = z.toJSONSchema;
  if (typeof toJSONSchema !== 'function') {
    throw new Error('Zod 4 is required: install zod@^4.0.0');
  }
  const compiled = toJSONSchema(schema, {
    target: 'draft-2020-12',
    unrepresentable: 'throw',
  });
  return {
    $schema: 'https://json-schema.org/draft/2020-12/schema',
    title: name,
    ...compiled,
  };
}

function validateZodSubset(schema, pathLabel, seen = new Set()) {
  if (seen.has(schema)) return;
  seen.add(schema);
  const def = schema?._def;
  if (!def) throw new Error(`${pathLabel}: expected a Zod schema`);
  const typeName = def.typeName ?? def.type;
  if (unsupportedZodType(typeName)) {
    throw new Error(`${pathLabel}: unsupported Zod feature ${typeName}`);
  }
  rejectUnsupportedChecks(def, pathLabel);
  for (const child of childSchemas(def)) {
    validateZodSubset(child.schema, `${pathLabel}${child.path}`, seen);
  }
}

function unsupportedZodType(typeName) {
  return new Set([
    'ZodEffects',
    'ZodPipeline',
    'ZodLazy',
    'ZodPromise',
    'ZodFunction',
    'ZodTransform',
    'pipe',
    'lazy',
    'promise',
    'function',
    'transform',
    'custom',
  ]).has(String(typeName));
}

function rejectUnsupportedChecks(def, pathLabel) {
  if (!Array.isArray(def.checks)) return;
  for (const [index, check] of def.checks.entries()) {
    const checkType = check?.def?.type ?? check?._def?.type ?? check?._zod?.def?.check;
    if (String(checkType) === 'custom') {
      throw new Error(`${pathLabel}.checks[${index}]: unsupported Zod feature custom`);
    }
  }
}

function childSchemas(def) {
  const children = [];
  if (def.innerType) children.push({ path: '.inner', schema: def.innerType });
  if (def.schema) children.push({ path: '.schema', schema: def.schema });
  if (def.valueType) children.push({ path: '.value', schema: def.valueType });
  if (def.keyType) children.push({ path: '.key', schema: def.keyType });
  if (Array.isArray(def.options)) def.options.forEach((schema, index) => children.push({ path: `.options[${index}]`, schema }));
  if (Array.isArray(def.items)) def.items.forEach((schema, index) => children.push({ path: `.items[${index}]`, schema }));
  const shape = typeof def.shape === 'function' ? def.shape() : def.shape;
  if (shape && typeof shape === 'object') {
    for (const [name, schema] of Object.entries(shape)) children.push({ path: `.shape.${name}`, schema });
  }
  return children;
}

// ---------------------------------------------------------------------------
// Nested block detection — walks generated JSON schemas to find blockRef
// patterns (objects with `component` enum + `props`) and validates that
// referenced component names exist in the registry.
// ---------------------------------------------------------------------------

function collectNestedBlockRefs(schema, knownNames, componentName) {
  const results = [];
  _walkSchemaForBlockRefs(schema, schema, '', results, knownNames, componentName);
  return results;
}

function _walkSchemaForBlockRefs(node, rootSchema, currentPath, results, knownNames, componentName) {
  if (!node || typeof node !== 'object') return;

  // Resolve $ref
  if (node.$ref) {
    const resolved = resolveJsonSchemaRef(node.$ref, rootSchema);
    if (resolved) _walkSchemaForBlockRefs(resolved, rootSchema, currentPath, results, knownNames, componentName);
    return;
  }

  // Check if this object matches the blockRef signature:
  // { type: "object", properties: { id, component, props } }
  if (isBlockRefSchema(node)) {
    const allow = extractBlockRefAllow(node, rootSchema);
    // Validate allowed names exist
    for (const name of allow) {
      if (!knownNames.has(name)) {
        throw new Error(
          `${componentName}: nested block allowlist references unknown component "${name}" at ${currentPath || '(root)'}`,
        );
      }
    }
    results.push({
      path: currentPath || '(root)',
      ...(allow.length > 0 ? { allow } : {}),
    });
    return; // Don't recurse into the block schema itself
  }

  // Recurse into object properties
  if (node.properties) {
    for (const [key, propSchema] of Object.entries(node.properties)) {
      const nextPath = currentPath ? `${currentPath}.${key}` : key;
      _walkSchemaForBlockRefs(propSchema, rootSchema, nextPath, results, knownNames, componentName);
    }
  }

  // Recurse into array items
  if (node.items) {
    _walkSchemaForBlockRefs(node.items, rootSchema, `${currentPath}[]`, results, knownNames, componentName);
  }

  // Recurse into anyOf / allOf / oneOf
  for (const keyword of ['anyOf', 'allOf', 'oneOf']) {
    if (Array.isArray(node[keyword])) {
      for (const sub of node[keyword]) {
        _walkSchemaForBlockRefs(sub, rootSchema, currentPath, results, knownNames, componentName);
      }
    }
  }
}

function isBlockRefSchema(node) {
  if (node.type !== 'object' || !node.properties) return false;
  const props = node.properties;
  // Must have component and props fields
  if (!props.component || !props.props) return false;
  // component must be a string or enum
  const comp = props.component;
  if (comp.type !== 'string' && !Array.isArray(comp.enum)) return false;
  // props must be an object (record)
  if (props.props.type !== 'object') return false;
  return true;
}

function extractBlockRefAllow(node, rootSchema) {
  let comp = node.properties.component;
  // Resolve $ref on the component field
  if (comp.$ref) {
    comp = resolveJsonSchemaRef(comp.$ref, rootSchema) ?? comp;
  }
  if (Array.isArray(comp.enum)) {
    return comp.enum.filter((v) => typeof v === 'string');
  }
  return [];
}

function resolveJsonSchemaRef(ref, rootSchema) {
  if (!ref || !ref.startsWith('#/')) return null;
  const parts = ref.slice(2).split('/');
  let current = rootSchema;
  for (const part of parts) {
    current = current?.[part];
  }
  return current ?? null;
}

function parseArgs(argv) {
  const out = {};
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i];
    if (!arg.startsWith('--')) continue;
    const body = arg.slice(2);
    const equals = body.indexOf('=');
    if (equals >= 0) {
      out[body.slice(0, equals)] = body.slice(equals + 1);
      continue;
    }
    out[body] = argv[++i];
  }
  return out;
}
