#!/usr/bin/env node
/**
 * validate-content — validate authored page content against compiled JSON schemas.
 *
 * Usage:
 *   node validate-content.mjs \
 *     --registry <path-to-compiled-registry-dir> \
 *     --compiled <path-to-compiled-content-dir>
 *
 * Prerequisites:
 *   1. Run compile-registry to generate registry.json + schemas/*.schema.json
 *   2. Run content build to generate compiled routes + fragments
 *
 * Validates every block in the COMPILED output against JSON schemas.
 * This validates the build result — after DAM resolution, fragment inlining, etc.
 * Fails with exit code 1 if any published page has validation errors.
 * Warns (but does not fail) for draft pages.
 *
 * Uses the JSON schemas generated by compile-registry — portable, no React/CSS
 * imports needed, same schemas usable by Go backend or other tools.
 */
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';

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

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

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

async function validateContent(options) {
  if (!options.registry) throw new Error('missing --registry (path to compiled registry directory)');
  if (!options.compiled) throw new Error('missing --compiled (path to compiled content output directory)');

  const registryDir = path.resolve(options.registry);
  const contentRoot = path.resolve(options.compiled);

  // Load compiled registry
  const registryPath = path.join(registryDir, 'registry.json');
  const registryRaw = await readFile(registryPath, 'utf-8');
  const registry = JSON.parse(registryRaw);

  // Build schema map: component name → JSON Schema object
  // Build nested blocks map: component name → array of { path, allow? }
  const schemaMap = new Map();
  const knownComponents = new Set();
  const nestedBlocksMap = new Map();
  for (const component of registry.components) {
    knownComponents.add(component.name);
    if (component.schemaFile) {
      const schemaPath = path.join(registryDir, component.schemaFile);
      const schemaRaw = await readFile(schemaPath, 'utf-8');
      schemaMap.set(component.name, JSON.parse(schemaRaw));
    }
    if (Array.isArray(component.nestedBlocks) && component.nestedBlocks.length > 0) {
      nestedBlocksMap.set(component.name, component.nestedBlocks);
    }
  }

  // Find all compiled route files
  const routesDir = path.join(contentRoot, 'routes');
  const pageFiles = await findJsonFiles(routesDir);

  const errors = [];
  const warnings = [];
  let pagesValidated = 0;
  let blocksValidated = 0;

  for (const pageFile of pageFiles) {
    const relativePath = path.relative(contentRoot, pageFile);
    let pageData;
    try {
      const raw = await readFile(pageFile, 'utf-8');
      pageData = JSON.parse(raw);
    } catch (err) {
      errors.push({ page: relativePath, message: `failed to parse JSON: ${err.message}` });
      continue;
    }

    pagesValidated++;
    const isPublished = pageData.status === 'published';
    const collector = isPublished ? errors : warnings;

    const blocks = collectBlocks(pageData);

    for (const block of blocks) {
      blocksValidated++;

      if (!block.component) {
        collector.push({
          page: relativePath,
          blockId: block.id ?? '<unknown>',
          message: 'block is missing "component" field',
        });
        continue;
      }

      // Check component exists in registry
      if (!knownComponents.has(block.component)) {
        collector.push({
          page: relativePath,
          blockId: block.id ?? '<unknown>',
          component: block.component,
          message: `unknown component: "${block.component}"`,
        });
        continue;
      }

      // Validate props against JSON Schema
      const schema = schemaMap.get(block.component);
      if (!schema) continue; // no schema file — component registered but no schema to check

      const props = block.props ?? {};
      const issues = validateJsonSchema(props, schema, 'props');
      for (const issue of issues) {
        collector.push({
          page: relativePath,
          blockId: block.id ?? '<unknown>',
          component: block.component,
          path: issue.path,
          message: issue.message,
        });
      }

      // Recursively validate nested blocks (blockRef patterns)
      const nestedIssues = validateNestedBlocks(
        block.component, props, knownComponents, schemaMap, nestedBlocksMap,
      );
      for (const issue of nestedIssues) {
        collector.push({
          page: relativePath,
          blockId: block.id ?? '<unknown>',
          component: block.component,
          ...issue,
        });
      }
    }
  }

  // Also validate fragments
  const fragmentsDir = path.join(contentRoot, 'fragments');
  const fragmentFiles = await findJsonFiles(fragmentsDir);

  for (const fragmentFile of fragmentFiles) {
    const relativePath = path.relative(contentRoot, fragmentFile);
    let fragmentData;
    try {
      const raw = await readFile(fragmentFile, 'utf-8');
      fragmentData = JSON.parse(raw);
    } catch (err) {
      errors.push({ fragment: relativePath, message: `failed to parse JSON: ${err.message}` });
      continue;
    }

    if (!fragmentData.component) continue;
    blocksValidated++;

    if (!knownComponents.has(fragmentData.component)) {
      errors.push({
        fragment: relativePath,
        component: fragmentData.component,
        message: `unknown component: "${fragmentData.component}"`,
      });
      continue;
    }

    const schema = schemaMap.get(fragmentData.component);
    if (!schema) continue;

    const props = fragmentData.props ?? {};
    const issues = validateJsonSchema(props, schema, 'props');
    for (const issue of issues) {
      errors.push({
        fragment: relativePath,
        component: fragmentData.component,
        path: issue.path,
        message: issue.message,
      });
    }
  }

  const success = errors.length === 0;
  return {
    success,
    pagesValidated,
    blocksValidated,
    errors,
    ...(warnings.length > 0 ? { warnings } : {}),
  };
}

// ---------------------------------------------------------------------------
// Lightweight JSON Schema draft-2020-12 validator
//
// Covers the subset that Zod generates via z.toJSONSchema:
//   type, required, properties, additionalProperties,
//   minLength, minimum, maximum, enum, const,
//   items, minItems, maxItems, anyOf, allOf, $ref (local)
// ---------------------------------------------------------------------------

function validateJsonSchema(value, schema, pathPrefix = '') {
  const issues = [];
  _validate(value, schema, pathPrefix, issues, schema);
  return issues;
}

function _validate(value, schema, currentPath, issues, rootSchema) {
  if (!schema || typeof schema !== 'object') return;

  // $ref — resolve local references
  if (schema.$ref) {
    const resolved = resolveRef(schema.$ref, rootSchema);
    if (resolved) {
      _validate(value, resolved, currentPath, issues, rootSchema);
    }
    return;
  }

  // anyOf — value must match at least one
  if (Array.isArray(schema.anyOf)) {
    const anyMatches = schema.anyOf.some((sub) => {
      const subIssues = [];
      _validate(value, sub, currentPath, subIssues, rootSchema);
      return subIssues.length === 0;
    });
    if (!anyMatches) {
      issues.push({ path: currentPath, message: `${currentPath}: does not match any allowed schema` });
    }
    return;
  }

  // allOf — value must match all
  if (Array.isArray(schema.allOf)) {
    for (const sub of schema.allOf) {
      _validate(value, sub, currentPath, issues, rootSchema);
    }
    return;
  }

  // const
  if ('const' in schema) {
    if (value !== schema.const) {
      issues.push({ path: currentPath, message: `${currentPath}: expected constant "${schema.const}", got ${JSON.stringify(value)}` });
    }
    return;
  }

  // enum
  if (Array.isArray(schema.enum)) {
    if (!schema.enum.includes(value)) {
      issues.push({ path: currentPath, message: `${currentPath}: expected one of [${schema.enum.join(', ')}], got ${JSON.stringify(value)}` });
    }
    return;
  }

  // type checking
  const types = schema.type ? (Array.isArray(schema.type) ? schema.type : [schema.type]) : null;
  if (types) {
    const actualType = getJsonType(value);
    const typeMatch = types.some((t) =>
      t === actualType || (t === 'integer' && actualType === 'number' && Number.isInteger(value))
    );
    if (!typeMatch) {
      // Allow null for nullable schemas
      if (value !== null || !types.includes('null')) {
        issues.push({ path: currentPath, message: `${currentPath}: expected type ${types.join('|')}, got ${actualType}` });
        return; // Stop further checks if type is wrong
      }
    }
  }

  if (value === null || value === undefined) return;

  // String checks
  if (typeof value === 'string') {
    if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
      issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} is less than minLength ${schema.minLength}` });
    }
    if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
      issues.push({ path: currentPath, message: `${currentPath}: string length ${value.length} exceeds maxLength ${schema.maxLength}` });
    }
  }

  // Number checks
  if (typeof value === 'number') {
    if (typeof schema.minimum === 'number' && value < schema.minimum) {
      issues.push({ path: currentPath, message: `${currentPath}: ${value} is less than minimum ${schema.minimum}` });
    }
    if (typeof schema.maximum === 'number' && value > schema.maximum) {
      issues.push({ path: currentPath, message: `${currentPath}: ${value} exceeds maximum ${schema.maximum}` });
    }
  }

  // Array checks
  if (Array.isArray(value)) {
    if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
      issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, minimum is ${schema.minItems}` });
    }
    if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
      issues.push({ path: currentPath, message: `${currentPath}: array has ${value.length} items, maximum is ${schema.maxItems}` });
    }
    if (schema.items) {
      for (let i = 0; i < value.length; i++) {
        _validate(value[i], schema.items, `${currentPath}[${i}]`, issues, rootSchema);
      }
    }
  }

  // Object checks
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
    // Required fields
    if (Array.isArray(schema.required)) {
      for (const key of schema.required) {
        if (!(key in value) || value[key] === undefined) {
          issues.push({ path: `${currentPath}.${key}`, message: `${currentPath}.${key}: required field is missing` });
        }
      }
    }
    // Properties
    if (schema.properties) {
      for (const [key, propSchema] of Object.entries(schema.properties)) {
        if (key in value) {
          _validate(value[key], propSchema, `${currentPath}.${key}`, issues, rootSchema);
        }
      }
    }
  }
}

function getJsonType(value) {
  if (value === null) return 'null';
  if (Array.isArray(value)) return 'array';
  return typeof value;
}

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

// ---------------------------------------------------------------------------
// Content helpers
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Nested block validation — recursively validates blocks inside blockRef slots
// ---------------------------------------------------------------------------

function validateNestedBlocks(parentComponent, props, knownComponents, schemaMap, nestedBlocksMap) {
  const issues = [];
  // Find all block-shaped arrays in props by walking the data
  const nestedBlocks = findNestedBlockArrays(props);
  for (const { path: blockPath, blocks } of nestedBlocks) {
    for (const [index, nestedBlock] of blocks.entries()) {
      if (!nestedBlock || typeof nestedBlock !== 'object') continue;
      const childComponent = nestedBlock.component;
      if (!childComponent || typeof childComponent !== 'string') continue;

      const nestedPath = `${blockPath}[${index}]`;

      // Check nested component exists in registry
      if (!knownComponents.has(childComponent)) {
        issues.push({
          path: `${nestedPath}.component`,
          message: `${parentComponent} → ${nestedPath}: unknown nested component "${childComponent}"`,
        });
        continue;
      }

      // Validate nested block's props against its own schema
      const childSchema = schemaMap.get(childComponent);
      if (!childSchema) continue;

      const childProps = nestedBlock.props ?? {};
      const childIssues = validateJsonSchema(childProps, childSchema, `${nestedPath}.props`);
      for (const issue of childIssues) {
        issues.push({
          nestedComponent: childComponent,
          path: issue.path,
          message: `${parentComponent} → ${childComponent}: ${issue.message}`,
        });
      }

      // Recurse further if the nested component itself has nested blocks
      const deeperIssues = validateNestedBlocks(
        childComponent, childProps, knownComponents, schemaMap, nestedBlocksMap,
      );
      issues.push(...deeperIssues);
    }
  }
  return issues;
}

/**
 * Walks a props object looking for arrays of block-shaped objects
 * (objects with `component` + `props` fields).
 */
function findNestedBlockArrays(obj, prefix = 'props') {
  const results = [];
  if (!obj || typeof obj !== 'object') return results;

  for (const [key, value] of Object.entries(obj)) {
    const currentPath = `${prefix}.${key}`;
    if (Array.isArray(value)) {
      // Check if array contains block-shaped objects
      const hasBlocks = value.some(
        (item) => item && typeof item === 'object' && typeof item.component === 'string' && 'props' in item,
      );
      if (hasBlocks) {
        results.push({ path: currentPath, blocks: value });
      }
      // Also recurse into array items that are objects (for nested structures like tabs[].blocks[])
      for (const item of value) {
        if (item && typeof item === 'object' && !Array.isArray(item)) {
          results.push(...findNestedBlockArrays(item, currentPath + '[]'));
        }
      }
    } else if (typeof value === 'object' && value !== null) {
      results.push(...findNestedBlockArrays(value, currentPath));
    }
  }
  return results;
}

function collectBlocks(pageData) {
  const blocks = [];
  if (pageData.regions && typeof pageData.regions === 'object') {
    for (const region of Object.values(pageData.regions)) {
      if (Array.isArray(region)) blocks.push(...region);
      else if (region && typeof region === 'object' && Array.isArray(region.children)) blocks.push(...region.children);
    }
  }
  if (Array.isArray(pageData.blocks)) blocks.push(...pageData.blocks);
  return blocks;
}

async function findPageFiles(dir) {
  return findFilesMatching(dir, (name) => name === '_index.json');
}

async function findJsonFiles(dir) {
  return findFilesMatching(dir, (name) => name.endsWith('.json'));
}

async function findFilesMatching(dir, predicate) {
  const results = [];
  try { await collectFiles(dir, predicate, results); }
  catch (err) { if (err.code !== 'ENOENT') throw err; }
  return results;
}

async function collectFiles(dir, predicate, results) {
  const entries = await readdir(dir, { withFileTypes: true });
  for (const entry of entries) {
    const full = path.join(dir, entry.name);
    if (entry.isDirectory()) await collectFiles(full, predicate, results);
    else if (predicate(entry.name)) results.push(full);
  }
}

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;
}
