#!/usr/bin/env node
/**
 * Mystique Manifest Validator v1.0
 * Automated linter for Fluent Commerce Mystique v2.0 manifest JSON files.
 * Implements all 66 validation rules from SKILL.md across 6 phases.
 *
 * Usage:
 *   node validator.mjs <manifest.json> [--strict] [--fix-suggestions] [--component <alias>] [--json]
 */

import { readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
import { resolve, basename, join, extname } from 'path';
import { fileURLToPath } from 'url';

// ─── Known Component Registry ──────────────────────────────────────────────
const KNOWN_ALIASES = new Set([
  // Layout & Container
  'fc.page','fc.page.wizard','fc.page.wizard.summary','fc.page.section',
  'fc.page.section.column','fc.page.section.header','fc.page.refresh',
  'fc.conditional','fc.repeater','fc.provider.graphql','fc.workflow.provider',
  'fc.tabs','fc.tabs.card','fc.tab.content','fc.accordion','fc.filterPanel',
  // Content & Display
  'fc.list','fc.card.attribute','fc.card.image','fc.card.product','fc.card.multi',
  'fc.card.attributes.grid','fc.card.map.point','fc.tile.metric',
  'fc.attribute.json','fc.attribute.column','fc.attribute.locationId',
  'fc.attribute.retailerId','fc.mystique.collapsible.text',
  'fc.mystique.collapsible.attributes','fc.mystique.link','fc.progress.circular',
  'fc.dashboard.threshold','fc.stepper','fc.json.viewer','fc.json.editor',
  'fc.quantity.list','fc.scanner.barcode','fc.scanner.camera',
  'fc.scanner.barcodeFilter','fc.settingForm','fc.action.inline',
  'fc.mutation.inline','fc.list.customAction',
  // Charts
  'fc.chart.area','fc.chart.area.wrapper.feed','fc.chart.bar',
  'fc.chart.bar.wrapper.source','fc.chart.line','fc.chart.gauge',
  'fc.chart.gauge.wrapper.threshold',
  // List wrappers
  'fc.list.wrapper.feed','fc.list.wrapper.source','fc.list.wrapper.bppmetrics',
  // Actions & Buttons
  'fc.button.print','fc.button.print.pick','fc.button.print.inline',
  'fc.button.print.inline.compatibility','fc.button.print.download',
  'fc.modal.button','fc.modal.button.addItem','fc.drawer.button',
  'fc.buttons.add.reject','fc.button.bar',
  'fc.sourcing.profile.drawer.button','fc.sourcing.profile.modal.button',
  'fc.sourcing.strategy.modal.button',
  // Fields
  'select','retailer','fc.field.daterange','daterange','fc.field.intrange',
  'intrange','fc.field.filterComplex','fc.field.multistring',
  'fc.page.filter.select','fc.attribute.jsoneditor','AttributeInput.list',
  'ExtendedAddressInput','fc.barcode.scanner','fc.action.field.wavepick',
  'fc.action.field.fulfilmentpack','FULFILMENT_PACK',
  'fc.action.field.multiparcel','MULTI_PARCEL_FIELD',
  'fc.action.field.returnitems','RETURN_ITEMS','FULFILMENT_IDS',
  'fc.daterange.wrapper.forwarder',
  // OMS
  'fc.events.search','fc.event.detail','fc.activity.entity',
  'fc.order.itemDetails','fc.order.shipmentDetails','fc.return.rowExpansion',
  'fc.reports.ipuipc',
  // Analytics
  'fc.analytics.viz',
]);

// Legacy alias → modern alias map
const LEGACY_ALIASES = {
  'shared.components.material.DynamicPage': 'fc.page',
  'shared.components.material.MaterialTabsSet': 'fc.tabs',
  'shared.components.material.SubLayoutContent': 'fc.tab.content',
  'shared.components.material.DynamicList': 'fc.list',
  'shared.components.material.DynamicCard': 'fc.card.attribute',
};

const VALID_CONTEXT_LEVELS = new Set(['account', 'retailer', 'location']);
const VALID_ROUTE_TYPES = new Set(['section', 'page', 'reference']);
const VALID_CARD_WIDTHS = new Set(['quarter', 'third', 'half', 'two-thirds', 'full']);
const VALID_COPY_ICON = new Set(['displayAlways', 'displayOnHover']);
const VALID_ATTR_TYPES = new Set(['image', 'component']);

// Custom helpers registered by plugins — populated via validate(path, { customHelpers: ['helperName'] })
let _customHelpers = new Set();

const KNOWN_HELPERS = new Set([
  // Comparison
  'eq','ne','gt','lt','gte','lte',
  // Logic
  'and','or','not','if','unless','in','switch','hasRole','firstDefinedValue','anyMatch',
  // Iteration
  'each','with','lookup',
  // String
  'capitalise','capitalize','humanify','toLowerCase','toUpperCase','concat','stringify',
  'substring','replace','split','join','includes','startsWith','endsWith',
  'preserveWhitespace','partialStringMatch','decodeUrlParams','currentUrlParams',
  // Placeholder
  'placeholderText','placeholderDate','placeholderArray',
  // Date
  'dateFormat','dateStringFormatter','dateTimeFormatter','dateAdd','dateSubtract',
  'dateRelative','dateFormatByLocale',
  // Number
  'currency','formatNumber','add','subtract','multiply','divide','pow','math',
  // Array
  'len','arraySum','length','pluralize',
  // Special
  'barcode','json','i18n',
]);

// ─── Issue Collector ────────────────────────────────────────────────────────
class Issues {
  constructor() { this.items = []; }
  add(severity, ruleId, message, path, fix) {
    this.items.push({ severity, ruleId, message, path, fix: fix || null });
  }
  get critical() { return this.items.filter(i => i.severity === 'CRITICAL'); }
  get high()     { return this.items.filter(i => i.severity === 'HIGH'); }
  get medium()   { return this.items.filter(i => i.severity === 'MEDIUM'); }
  get low()      { return this.items.filter(i => i.severity === 'LOW'); }
  get info()     { return this.items.filter(i => i.severity === 'INFO'); }
  get blocking() { return [...this.critical, ...this.high]; }
}

// ─── Phase 1: Schema Compliance ─────────────────────────────────────────────
function phase1(manifest, issues, isFragment) {
  const root = '(root)';

  // S01 — manifestVersion
  if (manifest.manifestVersion !== '2.0') {
    issues.add('CRITICAL', 'S01', `manifestVersion must be exactly "2.0", got "${manifest.manifestVersion}"`, root,
      'Add or fix: "manifestVersion": "2.0"');
  }

  if (!isFragment) {
    // S02 — name
    if (!manifest.name || typeof manifest.name !== 'string') {
      issues.add('CRITICAL', 'S02', 'Missing required field "name"', root, 'Add "name": "your-app-name"');
    } else if (/\s/.test(manifest.name)) {
      issues.add('CRITICAL', 'S02', `"name" must not contain spaces: "${manifest.name}"`, root,
        `Use kebab-case: "${manifest.name.replace(/\s+/g, '-').toLowerCase()}"`);
    }

    // S03 — homePath
    if (!manifest.homePath || typeof manifest.homePath !== 'string') {
      issues.add('CRITICAL', 'S03', 'Missing required field "homePath"', root, 'Add "homePath": "<route-path>"');
    }

    // S04 — routes non-empty
    if (!Array.isArray(manifest.routes) || manifest.routes.length === 0) {
      issues.add('CRITICAL', 'S04', 'Missing or empty "routes" array', root, 'Add at least one route');
    }

    // S05 — title
    if (!manifest.title || typeof manifest.title !== 'string') {
      issues.add('HIGH', 'S05', 'Missing required field "title"', root, 'Add "title": "App Title" or "title": "i18n:fc.app.title"');
    }
  } else {
    // S15 — fragment manifestVersion
    if (manifest.manifestVersion !== '2.0') {
      issues.add('HIGH', 'S15', 'Fragment must declare manifestVersion: "2.0"', root);
    }

    // S17 — fragment pages need section wrapper
    // Any root-level page won't appear in sidebar, even alongside valid sections
    if (Array.isArray(manifest.routes) && manifest.routes.length > 0) {
      const strayPages = manifest.routes.filter(r => r.type === 'page');
      if (strayPages.length > 0) {
        const paths = strayPages.map(p => p.path || '(no path)').join(', ');
        issues.add('HIGH', 'S17',
          `${strayPages.length} page(s) at fragment root without a section wrapper (${paths}) — sidebar menu items will be missing. ` +
          'Wrap routes in { "type": "section", "nav": { "label": "...", "icon": "..." }, "pages": [...] }',
          'routes',
          'Move page routes inside a section with nav: { label, icon }');
      }
    }
  }

  // S06 — context.level
  if (manifest.context && manifest.context.level) {
    if (!VALID_CONTEXT_LEVELS.has(manifest.context.level)) {
      issues.add('HIGH', 'S06', `context.level "${manifest.context.level}" is invalid. Must be: account, retailer, or location`, `${root}.context.level`);
    }
  }

  // S13 — plugins
  if (Array.isArray(manifest.plugins)) {
    manifest.plugins.forEach((p, i) => {
      const pp = `plugins[${i}]`;
      if (p.type === 'url') {
        if (!p.src || typeof p.src !== 'string' || p.src.trim() === '') {
          issues.add('MEDIUM', 'S13', `Plugin has empty or missing "src" URL`, pp, 'Provide a valid URL or remove this plugin entry');
        }
        if (!p.name) {
          issues.add('MEDIUM', 'S13', `Plugin missing "name" field`, pp, 'Add a "name" to identify this plugin');
        }
      } else if (p.type === 'setting') {
        if (!p.setting || typeof p.setting !== 'string') {
          issues.add('MEDIUM', 'S13', `Setting plugin missing "setting" field`, pp);
        }
      } else if (p.type) {
        issues.add('MEDIUM', 'S13', `Unknown plugin type "${p.type}"`, pp, 'Must be "url" or "setting"');
      } else {
        issues.add('MEDIUM', 'S13', `Plugin missing "type" field`, pp, 'Add "type": "url" or "type": "setting"');
      }
    });
  }

  // Validate routes structure
  if (Array.isArray(manifest.routes)) {
    manifest.routes.forEach((route, i) => {
      const rp = `routes[${i}]`;
      // S07 — route type
      if (!route.type || !VALID_ROUTE_TYPES.has(route.type)) {
        issues.add('HIGH', 'S07', `Route type "${route.type}" is invalid. Must be: section, page, or reference`, rp);
      }

      if (route.type === 'section') {
        // S10 — section pages
        if (!Array.isArray(route.pages) || route.pages.length === 0) {
          issues.add('MEDIUM', 'S10', 'Section has no pages', rp, 'Add a "pages" array with at least one page');
        }
        // S11 — section nav
        if (!route.nav || !route.nav.label || !route.nav.icon) {
          issues.add('MEDIUM', 'S11', 'Section missing nav label/icon for sidebar rendering', rp,
            'Add "nav": { "label": "i18n:fc.section.nav", "icon": "icon_name" }');
        }
        // Validate pages within section
        if (Array.isArray(route.pages)) {
          route.pages.forEach((page, j) => {
            validatePage(page, `${rp}.pages[${j}]`, issues);
          });
        }
      } else if (route.type === 'page') {
        validatePage(route, rp, issues);
      } else if (route.type === 'reference') {
        // S12
        if (!route.settingName || typeof route.settingName !== 'string') {
          issues.add('HIGH', 'S12', 'Fragment reference missing "settingName"', rp, 'Add "settingName": "fc.mystique.manifest.fragment.name"');
        }
      }
    });
  }
}

function validatePage(page, path, issues) {
  // S08
  if (!page.path || typeof page.path !== 'string') {
    issues.add('HIGH', 'S08', 'Page missing required "path"', path, 'Add "path": "your-path"');
  }
  // S09
  if (!page.component || typeof page.component !== 'string') {
    issues.add('HIGH', 'S09', 'Page missing required "component"', path, 'Add "component": "fc.page"');
  }
  // S14
  if (page.descendants !== undefined && !Array.isArray(page.descendants)) {
    issues.add('MEDIUM', 'S14', '"descendants" must be an array', path);
  }
}

// ─── Phase 2: Component Resolution ─────────────────────────────────────────
function phase2(manifest, issues) {
  const pagePaths = [];
  const pluginNames = [];

  // Collect page paths for C02 and C03
  function collectPaths(routes, prefix) {
    if (!Array.isArray(routes)) return;
    for (const route of routes) {
      if (route.type === 'section' && Array.isArray(route.pages)) {
        for (const page of route.pages) {
          if (page.path) pagePaths.push({ path: page.path, loc: prefix });
        }
      } else if (route.type === 'page' && route.path) {
        pagePaths.push({ path: route.path, loc: prefix });
      }
    }
  }
  collectPaths(manifest.routes, 'routes');

  // C02 — duplicate route paths
  const pathCounts = {};
  for (const { path, loc } of pagePaths) {
    if (!pathCounts[path]) pathCounts[path] = [];
    pathCounts[path].push(loc);
  }
  for (const [p, locs] of Object.entries(pathCounts)) {
    if (locs.length > 1) {
      issues.add('HIGH', 'C02', `Duplicate route path "${p}" found on ${locs.length} pages`, 'routes',
        'Remove or rename the duplicate page');
    }
  }

  // C03 — homePath resolves
  if (manifest.homePath && typeof manifest.homePath === 'string') {
    const allPaths = pagePaths.map(p => p.path);
    if (!allPaths.includes(manifest.homePath)) {
      issues.add('CRITICAL', 'C03', `homePath "${manifest.homePath}" does not match any defined route path`, '(root).homePath',
        `Available paths: ${allPaths.join(', ')}`);
    }
  }

  // C04 — fragment settingName convention
  if (Array.isArray(manifest.routes)) {
    manifest.routes.forEach((r, i) => {
      if (r.type === 'reference' && r.settingName && !r.settingName.startsWith('fc.mystique.manifest.')) {
        issues.add('MEDIUM', 'C04', `Fragment settingName "${r.settingName}" doesn't follow "fc.mystique.manifest.*" convention`, `routes[${i}]`);
      }
    });
  }

  // C05 — plugin name uniqueness
  if (Array.isArray(manifest.plugins)) {
    const seen = {};
    manifest.plugins.forEach((p, i) => {
      if (p.name) {
        if (seen[p.name] !== undefined) {
          issues.add('MEDIUM', 'C05', `Duplicate plugin name "${p.name}"`, `plugins[${i}]`,
            `First seen at plugins[${seen[p.name]}]`);
        } else {
          seen[p.name] = i;
        }
      }
    });
  }

  // Walk component tree for C01, C06, C07
  walkComponents(manifest, issues);
}

function walkComponents(manifest, issues) {
  if (!Array.isArray(manifest.routes)) return;

  function walk(node, path, depth) {
    // C06 — nesting depth
    if (depth > 8) {
      issues.add('LOW', 'C06', `Component nesting depth is ${depth} levels (max recommended: 8)`, path,
        'Consider flattening the component tree');
    }

    if (node.component) {
      // C01 — alias resolution
      const alias = node.component;
      if (!KNOWN_ALIASES.has(alias) && !LEGACY_ALIASES[alias]) {
        if (alias.includes('.') && (alias.startsWith('custom.') || !alias.startsWith('fc.'))) {
          issues.add('INFO', 'C01', `Custom component alias "${alias}" — verify it is registered in a loaded plugin`, path);
        } else if (!alias.startsWith('fc.')) {
          issues.add('INFO', 'C01', `Non-standard component alias "${alias}" — verify registration`, path);
        } else {
          issues.add('HIGH', 'C01', `Unknown component alias "${alias}"`, path,
            suggestAlias(alias));
        }
      }

      // C07 — role string format
      if (Array.isArray(node.roles)) {
        for (const role of node.roles) {
          if (typeof role === 'string' && !/^[A-Z][A-Z0-9_]*$/.test(role)) {
            issues.add('LOW', 'C07', `Role "${role}" doesn't match expected format (UPPER_SNAKE_CASE)`, path);
          }
        }
      }

      // S16 — dataSource placement (should be sibling of component, not inside props)
      // Children.tsx has a fallback (comp.dataSource || comp.props?.['dataSource'])
      // so it may work via the standard path, but components bypassing <Children />
      // won't see it. The canonical MystiqueComponentInstance interface defines
      // dataSource as a peer of component/props — always use that placement.
      if (node.props && typeof node.props === 'object' && node.props.dataSource !== undefined) {
        issues.add('HIGH', 'S16',
          `"dataSource" found inside "props" — should be a sibling of "component". ` +
          `The core renderer has a fallback but components bypassing <Children /> will not see it. ` +
          `Move to: { "component": "${alias}", "dataSource": "${node.props.dataSource}", "props": {...} }`,
          path,
          `Move "dataSource": "${node.props.dataSource}" from props to the component instance level`);
      }
    }

    // Recurse into descendants
    if (Array.isArray(node.descendants)) {
      node.descendants.forEach((child, i) => {
        walk(child, `${path}.descendants[${i}]`, depth + 1);
      });
    }
    // Recurse into pages (for sections)
    if (Array.isArray(node.pages)) {
      node.pages.forEach((page, i) => {
        walk(page, `${path}.pages[${i}]`, depth);
      });
    }
  }

  manifest.routes.forEach((route, i) => {
    walk(route, `routes[${i}]`, 0);
  });
}

function suggestAlias(alias) {
  // Simple Levenshtein-ish suggestion
  const candidates = [...KNOWN_ALIASES];
  let best = null, bestDist = Infinity;
  for (const c of candidates) {
    const d = simpleDist(alias, c);
    if (d < bestDist) { bestDist = d; best = c; }
  }
  if (bestDist <= 5) return `Did you mean "${best}"?`;
  return 'Check the component registry for available aliases';
}

function simpleDist(a, b) {
  // Simple character overlap distance
  const setA = new Set(a.split('.'));
  const setB = new Set(b.split('.'));
  let common = 0;
  for (const s of setA) if (setB.has(s)) common++;
  return (setA.size + setB.size) - 2 * common;
}

// ─── Phase 3: Data and Query Validation ─────────────────────────────────────
function phase3(manifest, issues) {
  if (!Array.isArray(manifest.routes)) return;

  function checkData(node, path) {
    // Page-level data.query
    if (node.data && node.data.query) {
      const q = node.data.query;
      // D01 — basic GraphQL syntax
      validateGraphQLSyntax(q, `${path}.data.query`, issues);

      // D04 — unused variables
      if (node.data.variables && typeof node.data.variables === 'object') {
        for (const varName of Object.keys(node.data.variables)) {
          if (!q.includes(`$${varName}`)) {
            issues.add('LOW', 'D04', `Unused variable "${varName}" in query variables`, `${path}.data.variables`,
              `"$${varName}" does not appear in the query string. Remove it.`);
          }
        }
      }

      // T05 — _locale='en' on date helpers in variables
      if (node.data.variables && typeof node.data.variables === 'object') {
        for (const [vName, vVal] of Object.entries(node.data.variables)) {
          if (typeof vVal === 'string' && (vVal.includes('dateFormat') || vVal.includes('dateStringFormatter') || vVal.includes('dateTimeFormatter'))) {
            if (!vVal.includes("_locale='en'") && !vVal.includes('_locale="en"')) {
              issues.add('HIGH', 'T05', `Date helper in variable "${vName}" missing _locale='en' — user locale may produce non-ASCII digits`, `${path}.data.variables.${vName}`,
                `Add _locale='en' to the helper call`);
            }
          }
        }
      }
    }

    // D06 — fc.provider.graphql requires query
    if (node.component === 'fc.provider.graphql') {
      if (!node.props || !node.props.query) {
        issues.add('HIGH', 'D06', 'fc.provider.graphql missing required "query" prop', path,
          'Add "query": "query(...) { ... }" to props');
      } else {
        validateGraphQLSyntax(node.props.query, `${path}.props.query`, issues);
      }
    }

    // D02 — dataSource path format
    if (node.dataSource && typeof node.dataSource === 'string') {
      const ds = node.dataSource;
      if (ds.startsWith('.') || ds.endsWith('.') || ds.includes('..')) {
        issues.add('MEDIUM', 'D02', `Invalid dataSource path "${ds}" — contains leading/trailing/double dots`, path,
          `Fix to "${ds.replace(/\.{2,}/g, '.').replace(/^\./, '').replace(/\.$/, '')}"`);
      }
    }

    // Recurse
    if (Array.isArray(node.descendants)) {
      node.descendants.forEach((child, i) => checkData(child, `${path}.descendants[${i}]`));
    }
    if (Array.isArray(node.pages)) {
      node.pages.forEach((page, i) => checkData(page, `${path}.pages[${i}]`));
    }
  }

  manifest.routes.forEach((route, i) => checkData(route, `routes[${i}]`));

  // D03 — dataSource root segment should match a page query field
  // Collect query root fields per page, then check component dataSources against them.
  function extractQueryRoots(queryStr) {
    if (typeof queryStr !== 'string') return [];
    const roots = [];
    // Match top-level field names after the opening { of the query body.
    // e.g. "query($id: ID!) { orderById(id: $id) { ... } me { ... } }" -> ["orderById", "me"]
    const bodyMatch = queryStr.match(/\)\s*\{(.+)\}$/s) || queryStr.match(/^\s*\{(.+)\}$/s) || queryStr.match(/query\s*\{(.+)\}$/s);
    if (!bodyMatch) return roots;
    const body = bodyMatch[1];
    // Extract top-level field names (word before optional ( or {)
    const fieldPattern = /(?:^|[}\s])\s*([a-zA-Z_]\w*)\s*[({:]/g;
    let m;
    let depth = 0;
    for (let i = 0; i < body.length; i++) {
      if (body[i] === '{') depth++;
      if (body[i] === '}') depth--;
    }
    // Simpler approach: just find words at brace depth 0
    depth = 0;
    let tokenStart = -1;
    for (let i = 0; i < body.length; i++) {
      const ch = body[i];
      if (ch === '{') { depth++; continue; }
      if (ch === '}') { depth--; continue; }
      if (depth === 0) {
        if (/[a-zA-Z_]/.test(ch) && tokenStart < 0) {
          tokenStart = i;
        } else if (tokenStart >= 0 && !/[a-zA-Z0-9_]/.test(ch)) {
          const token = body.substring(tokenStart, i);
          // Skip GraphQL keywords
          if (token !== 'query' && token !== 'mutation' && token !== 'fragment' && token !== 'on') {
            roots.push(token);
          }
          tokenStart = -1;
        }
      } else {
        tokenStart = -1;
      }
    }
    if (tokenStart >= 0) {
      const token = body.substring(tokenStart);
      if (token !== 'query' && token !== 'mutation' && token !== 'fragment' && token !== 'on') {
        roots.push(token);
      }
    }
    return roots;
  }

  function checkDataSourceRoots(route, routePath) {
    // Collect known query root fields for this page
    const knownRoots = new Set();
    if (route.data && route.data.query) {
      extractQueryRoots(route.data.query).forEach(r => knownRoots.add(r));
    }
    if (route.dataSources && typeof route.dataSources === 'object') {
      for (const key of Object.keys(route.dataSources)) {
        knownRoots.add(key);
        const ds = route.dataSources[key];
        if (ds && ds.query) {
          extractQueryRoots(ds.query).forEach(r => knownRoots.add(r));
        }
      }
    }
    if (knownRoots.size === 0) return;

    function walkForD03(node, nodePath) {
      // Skip fc.provider.graphql descendants — they have their own data context
      if (node.component === 'fc.provider.graphql') return;

      if (node.dataSource && typeof node.dataSource === 'string' && node.dataSource.length > 0) {
        const rootSegment = node.dataSource.split('.')[0];
        // Skip template expressions — they resolve dynamically
        if (!rootSegment.includes('{') && !knownRoots.has(rootSegment)) {
          const suggestion = [...knownRoots].join(', ');
          issues.add('MEDIUM', 'D03',
            `dataSource root "${rootSegment}" (from "${node.dataSource}") does not match any page query field. Known roots: [${suggestion}]`,
            nodePath,
            `Change dataSource to start with one of: ${suggestion}`);
        }
      }
      if (Array.isArray(node.descendants)) {
        node.descendants.forEach((child, i) => walkForD03(child, `${nodePath}.descendants[${i}]`));
      }
    }

    // Walk page descendants
    if (Array.isArray(route.descendants)) {
      route.descendants.forEach((child, i) => walkForD03(child, `${routePath}.descendants[${i}]`));
    }
    // Walk children inside props.children (fc.page pattern)
    if (route.props && Array.isArray(route.props.children)) {
      route.props.children.forEach((child, i) => walkForD03(child, `${routePath}.props.children[${i}]`));
    }
    // Walk pages (section routes)
    if (Array.isArray(route.pages)) {
      route.pages.forEach((page, i) => checkDataSourceRoots(page, `${routePath}.pages[${i}]`));
    }
  }

  manifest.routes.forEach((route, i) => checkDataSourceRoots(route, `routes[${i}]`));
}

function validateGraphQLSyntax(query, path, issues) {
  if (typeof query !== 'string') {
    issues.add('HIGH', 'D01', 'Query is not a string', path);
    return;
  }
  // Check balanced braces
  let braces = 0, parens = 0;
  for (const ch of query) {
    if (ch === '{') braces++;
    if (ch === '}') braces--;
    if (ch === '(') parens++;
    if (ch === ')') parens--;
    if (braces < 0 || parens < 0) break;
  }
  if (braces !== 0) {
    issues.add('HIGH', 'D01', `Unbalanced braces in GraphQL query (balance: ${braces})`, path, 'Check for missing { or }');
  }
  if (parens !== 0) {
    issues.add('HIGH', 'D01', `Unbalanced parentheses in GraphQL query (balance: ${parens})`, path, 'Check for missing ( or )');
  }
  // Must start with query or mutation
  const trimmed = query.trim();
  if (!trimmed.startsWith('query') && !trimmed.startsWith('mutation') && !trimmed.startsWith('{')) {
    issues.add('HIGH', 'D01', 'GraphQL query must start with "query", "mutation", or "{"', path);
  }
}

// ─── Phase 4: Template String Validation ────────────────────────────────────
function phase4(manifest, issues) {
  if (!Array.isArray(manifest.routes)) return;

  function checkTemplates(node, path) {
    // Check all string values in props
    if (node.props && typeof node.props === 'object') {
      walkStrings(node.props, `${path}.props`, issues, node);
    }
    // Check dataSource
    if (typeof node.dataSource === 'string') {
      checkTemplateString(node.dataSource, `${path}.dataSource`, issues);
    }

    // Recurse
    if (Array.isArray(node.descendants)) {
      node.descendants.forEach((child, i) => checkTemplates(child, `${path}.descendants[${i}]`));
    }
    if (Array.isArray(node.pages)) {
      node.pages.forEach((page, i) => checkTemplates(page, `${path}.pages[${i}]`));
    }
  }

  manifest.routes.forEach((route, i) => checkTemplates(route, `routes[${i}]`));
}

function walkStrings(obj, path, issues, componentNode) {
  if (typeof obj === 'string') {
    checkTemplateString(obj, path, issues);
    // T03 — i18n key format
    if (obj.startsWith('i18n:')) {
      const key = obj.substring(5);
      if (/\s/.test(key) || /[!@#$%^&*()+=\[\]{}|\\;:'",<>?/]/.test(key)) {
        issues.add('MEDIUM', 'T03', `i18n key "${obj}" contains spaces or special characters`, path,
          'Use dot-separated format: "i18n:fc.app.keyName"');
      }
    }
    // T04 — rowLink
    if (path.endsWith('.rowLink') && obj && !obj.startsWith('/') && !obj.startsWith('{{')) {
      issues.add('MEDIUM', 'T04', `rowLink "${obj}" should start with "/" or a template expression`, path);
    }
    // T06 — triple-brace
    if (obj.includes('{{{') && !obj.includes('currentUrlParams')) {
      issues.add('LOW', 'T06', `Triple-brace unescaped output detected: may create XSS risks`, path,
        'Use {{}} (double-brace) unless you specifically need unescaped HTML');
    }
    return;
  }
  if (Array.isArray(obj)) {
    obj.forEach((item, i) => walkStrings(item, `${path}[${i}]`, issues, componentNode));
    return;
  }
  if (obj && typeof obj === 'object') {
    for (const [key, val] of Object.entries(obj)) {
      walkStrings(val, `${path}.${key}`, issues, componentNode);
    }
  }
}

function checkTemplateString(str, path, issues) {
  if (typeof str !== 'string') return;

  // T01 — balanced delimiters
  // Count {{ and }} (but not {{{ and }}})
  const opens = (str.match(/\{\{/g) || []).length;
  const closes = (str.match(/\}\}/g) || []).length;
  if (opens !== closes) {
    issues.add('MEDIUM', 'T01', `Unbalanced template delimiters in "${truncate(str, 60)}" (${opens} opens, ${closes} closes)`, path,
      'Ensure every {{ has a matching }}');
  }

  // T02 — known helpers
  const helperPattern = /\{\{(?:\{)?#?\s*([a-zA-Z_][a-zA-Z0-9_]*)/g;
  let match;
  while ((match = helperPattern.exec(str)) !== null) {
    const helper = match[1];
    // Skip simple field access (no dot, lowercase first char) — these are likely field names
    // Only flag if it looks like a helper call (followed by a space and argument)
    const afterHelper = str.substring(match.index + match[0].length);
    if (afterHelper.match(/^\s+[^\}]/) && !KNOWN_HELPERS.has(helper) && !_customHelpers.has(helper)) {
      // Check if it's a context variable (activeUser, activeRetailer, etc.)
      if (!helper.startsWith('active') && !helper.startsWith('params') && helper !== 'node') {
        // Layer 1: Namespace-prefix auto-detect — <namespace>.helpers.<name> pattern
        if (/^[a-z]+\.helpers\.[a-z]/.test(helper)) {
          issues.add('INFO', 'T02', `Custom helper "${helper}" — verify SDK plugin is loaded in root manifest plugins[]`, path,
            'Custom helpers must be registered via TemplateRegistry.register() in a deployed plugin bundle.');
        } else {
          issues.add('LOW', 'T02', `Unknown template helper "${helper}"`, path,
            'Check the Mystique template helper reference. If this is a plugin-registered helper, pass customHelpers option.');
        }
      }
    }
  }
}

// ─── Phase 5: Component Props Validation ────────────────────────────────────
function phase5(manifest, issues, componentFilter) {
  if (!Array.isArray(manifest.routes)) return;

  function checkProps(node, path) {
    const alias = node.component;
    if (!alias) {
      // Recurse into section pages
      if (Array.isArray(node.pages)) {
        node.pages.forEach((page, i) => checkProps(page, `${path}.pages[${i}]`));
      }
      if (Array.isArray(node.descendants)) {
        node.descendants.forEach((child, i) => checkProps(child, `${path}.descendants[${i}]`));
      }
      return;
    }

    if (componentFilter && alias !== componentFilter) {
      // Skip non-matching components, but still recurse
      if (Array.isArray(node.descendants)) {
        node.descendants.forEach((child, i) => checkProps(child, `${path}.descendants[${i}]`));
      }
      return;
    }

    const props = node.props || {};

    switch (alias) {
      case 'fc.list':
      case 'shared.components.material.DynamicList':
        // P01
        if (!props.attributes || !Array.isArray(props.attributes) || props.attributes.length === 0) {
          issues.add('HIGH', 'P01', 'fc.list missing required "attributes" prop', path,
            'Add "attributes": [{ "label": "...", "value": "{{...}}" }]');
        } else {
          // P03
          props.attributes.forEach((attr, ai) => {
            if (!attr.label) {
              issues.add('MEDIUM', 'P03', `fc.list attribute[${ai}] missing "label"`, `${path}.props.attributes[${ai}]`);
            }
          });
        }
        // P02
        if (props.defaultPageSize === undefined) {
          issues.add('MEDIUM', 'P02', 'fc.list missing "defaultPageSize" (defaults to 10)', path,
            'Add "defaultPageSize": 25');
        }
        // P04
        if (!node.dataSource) {
          issues.add('HIGH', 'P04', 'fc.list missing required "dataSource"', path,
            'Add "dataSource": "<connection-path>"');
        }
        break;

      case 'fc.card.attribute':
      case 'shared.components.material.DynamicCard':
        // P05
        if (!props.attributes || !Array.isArray(props.attributes) || props.attributes.length === 0) {
          issues.add('HIGH', 'P05', 'fc.card.attribute missing required "attributes" prop', path,
            'Add "attributes": [{ "label": "...", "value": "{{...}}" }]');
        } else {
          // P19, P20, P21 — dynamic attribute types
          props.attributes.forEach((attr, ai) => {
            const ap = `${path}.props.attributes[${ai}]`;
            if (attr.type) {
              if (!VALID_ATTR_TYPES.has(attr.type)) {
                issues.add('MEDIUM', 'P19', `Invalid attribute type "${attr.type}" — must be "image" or "component"`, ap);
              }
              if (attr.type === 'image' && !attr.value) {
                issues.add('MEDIUM', 'P20', 'Image attribute missing "value" URL', ap);
              }
              if (attr.type === 'component' && (!attr.options || !attr.options.component)) {
                issues.add('HIGH', 'P21', 'Component attribute missing "options.component"', ap);
              }
            }
            // P22 — icon prefix
            if (attr.options && attr.options.styles && Array.isArray(attr.options.styles)) {
              for (const style of attr.options.styles) {
                if (style.icon && style.icon.name && !/^(Md|Fa|Io|Bs|Ai|Bi|Cg|Di|Fi|Gi|Go|Gr|Hi|Im|Ri|Si|Ti|Vsc|Wi)/.test(style.icon.name)) {
                  issues.add('LOW', 'P22', `Icon "${style.icon.name}" missing library prefix (e.g., "Md" for Material Design)`, ap);
                }
              }
            }
            // P23 — enableCopyIcon
            if (attr.enableCopyIcon && !VALID_COPY_ICON.has(attr.enableCopyIcon)) {
              issues.add('LOW', 'P23', `Invalid enableCopyIcon value "${attr.enableCopyIcon}" — must be "displayAlways" or "displayOnHover"`, ap);
            }
          });
        }
        break;

      case 'fc.conditional':
        // P06
        if (!props.value) {
          issues.add('HIGH', 'P06', 'fc.conditional missing required "value" prop', path,
            'Add "value": "{{templateExpression}}"');
        }
        if (!props.matches && props.matches !== '') {
          issues.add('HIGH', 'P06', 'fc.conditional missing required "matches" prop', path,
            'Add "matches": "expectedValue" or "matches": ["val1", "val2"]');
        }
        break;

      case 'fc.repeater':
        // P07
        if (!node.dataSource) {
          issues.add('HIGH', 'P07', 'fc.repeater missing required "dataSource"', path,
            'Add "dataSource": "<array-or-connection-path>"');
        }
        // P08
        if (!Array.isArray(node.descendants) || node.descendants.length === 0) {
          issues.add('HIGH', 'P08', 'fc.repeater has no descendants to repeat', path,
            'Add at least one descendant component');
        }
        break;

      case 'fc.provider.graphql':
        // P09 (also D06)
        if (!props.query) {
          issues.add('HIGH', 'P09', 'fc.provider.graphql missing required "query" prop', path,
            'Add "query": "query(...) { ... }"');
        }
        break;

      case 'fc.tabs.card':
        // P10
        if (!props.tabs || !Array.isArray(props.tabs) || props.tabs.length === 0) {
          issues.add('HIGH', 'P10', 'fc.tabs.card missing required "tabs" prop', path,
            'Add "tabs": [{ "label": "Tab 1" }, { "label": "Tab 2" }]');
        }
        // P11 — tabs/descendants count
        if (Array.isArray(props.tabs) && Array.isArray(node.descendants)) {
          if (props.tabs.length !== node.descendants.length) {
            issues.add('MEDIUM', 'P11', `fc.tabs.card has ${props.tabs.length} tabs but ${node.descendants.length} descendants — mismatch causes empty/unreachable tabs`, path);
          }
        }
        break;

      case 'fc.tabs':
      case 'shared.components.material.MaterialTabsSet':
        if (!props.layouts || !Array.isArray(props.layouts) || props.layouts.length === 0) {
          issues.add('HIGH', 'P10', 'fc.tabs missing required "layouts" prop', path,
            'Add "layouts": [{ "label": "Tab" }]');
        }
        break;

      case 'fc.tile.metric':
        // P12
        if (!props.label) {
          issues.add('HIGH', 'P12', 'fc.tile.metric missing required "label" prop', path);
        }
        if (!props.value && props.value !== 0) {
          issues.add('HIGH', 'P12', 'fc.tile.metric missing required "value" prop', path);
        }
        break;

      case 'fc.mystique.collapsible.text':
        // P13
        if (!props.text) {
          issues.add('HIGH', 'P13', 'fc.mystique.collapsible.text missing required "text" prop', path);
        }
        if (props.charCutoff === undefined) {
          issues.add('HIGH', 'P13', 'fc.mystique.collapsible.text missing required "charCutoff" prop', path,
            'Add "charCutoff": 100');
        }
        break;

      case 'fc.card.image':
        // P14
        if (!props.image) {
          issues.add('HIGH', 'P14', 'fc.card.image missing required "image" prop', path,
            'Add "image": { "imageUrl": "{{...}}" }');
        }
        break;

      case 'fc.page':
      case 'shared.components.material.DynamicPage':
        // P15
        if (!props.title) {
          issues.add('LOW', 'P15', 'fc.page missing "title" prop — page header will be blank', path);
        }
        break;

      case 'fc.settingForm':
        // P18
        if (!props.setting) {
          issues.add('MEDIUM', 'P18', 'fc.settingForm missing "setting" reference', path,
            'Add "setting": "fc.setting.name"');
        }
        break;
    }

    // P16 — page component recommendation
    if (node.type === 'page' && node.component &&
        node.component !== 'fc.page' && node.component !== 'fc.page.wizard') {
      issues.add('LOW', 'P16', `Page uses component "${node.component}" instead of fc.page or fc.page.wizard`, path);
    }

    // P17 — width validation
    if (props.width !== undefined) {
      const w = props.width;
      if (typeof w === 'string' && !VALID_CARD_WIDTHS.has(w)) {
        issues.add('LOW', 'P17', `Invalid CardWidth "${w}" — use quarter, third, half, two-thirds, full, or 1-12`, path);
      } else if (typeof w === 'number' && (w < 1 || w > 12)) {
        issues.add('LOW', 'P17', `CardWidth number ${w} out of range (1-12)`, path);
      }
    }

    // Recurse
    if (Array.isArray(node.descendants)) {
      node.descendants.forEach((child, i) => checkProps(child, `${path}.descendants[${i}]`));
    }
  }

  manifest.routes.forEach((route, i) => checkProps(route, `routes[${i}]`));
}

// ─── Phase 6: Cross-Manifest and Plugin Validation ──────────────────────────
function phase6(manifest, issues) {
  if (Array.isArray(manifest.plugins)) {
    manifest.plugins.forEach((p, i) => {
      const pp = `plugins[${i}]`;
      if (p.type === 'url' && p.src && typeof p.src === 'string') {
        // X02
        if (!p.src.startsWith('http://') && !p.src.startsWith('https://')) {
          issues.add('MEDIUM', 'X02', `Plugin src "${truncate(p.src, 50)}" is not a valid URL`, pp);
        }
        // X03
        if (p.src.includes('localhost') || p.src.includes('127.0.0.1')) {
          issues.add('LOW', 'X03', `Plugin src contains localhost — development URL will fail in production`, pp,
            'Replace with production CDN URL before deploying');
        }
        // X04
        if (p.src.startsWith('http://') && !p.src.includes('localhost') && !p.src.includes('127.0.0.1')) {
          issues.add('LOW', 'X04', `Plugin src uses HTTP instead of HTTPS`, pp,
            'Use HTTPS for production environments');
        }
      }
    });
  }
}

// ─── Utilities ──────────────────────────────────────────────────────────────
function truncate(str, max) {
  if (str.length <= max) return str;
  return str.substring(0, max - 3) + '...';
}

// ─── Discovery ──────────────────────────────────────────────────────────────
function discoverManifests(inputPath) {
  const stat = statSync(inputPath);
  if (stat.isFile()) {
    return [inputPath];
  }
  const files = [];
  const SKIP = new Set(['node_modules', 'dist', '.git', 'build']);
  function walk(dir) {
    for (const entry of readdirSync(dir)) {
      if (SKIP.has(entry)) continue;
      const full = join(dir, entry);
      try {
        const s = statSync(full);
        if (s.isDirectory()) walk(full);
        else if (extname(full) === '.json') {
          try {
            const content = readFileSync(full, 'utf8');
            if (content.includes('"manifestVersion"')) files.push(full);
          } catch {}
        }
      } catch {}
    }
  }
  walk(inputPath);
  return files;
}

// ─── Report Formatter ───────────────────────────────────────────────────────
function formatReport(file, manifest, issues, opts) {
  const lines = [];
  const isFragment = !manifest.name && !manifest.homePath;

  // Count components
  let componentCount = 0, uniqueAliases = new Set();
  function countComponents(node) {
    if (node.component) { componentCount++; uniqueAliases.add(node.component); }
    if (Array.isArray(node.descendants)) node.descendants.forEach(countComponents);
    if (Array.isArray(node.pages)) node.pages.forEach(countComponents);
  }
  if (Array.isArray(manifest.routes)) manifest.routes.forEach(countComponents);

  // Count routes
  let sectionCount = 0, pageCount = 0, refCount = 0;
  if (Array.isArray(manifest.routes)) {
    for (const r of manifest.routes) {
      if (r.type === 'section') { sectionCount++; pageCount += (r.pages || []).length; }
      else if (r.type === 'page') pageCount++;
      else if (r.type === 'reference') refCount++;
    }
  }

  const pluginCount = (manifest.plugins || []).length;
  const urlPlugins = (manifest.plugins || []).filter(p => p.type === 'url').length;
  const settingPlugins = (manifest.plugins || []).filter(p => p.type === 'setting').length;

  lines.push('+----------------------------------------------------------+');
  lines.push('|  Mystique Manifest Validation Report                      |');
  lines.push('+----------------------------------------------------------+');
  lines.push(`|  File:       ${basename(file).padEnd(43)}|`);
  lines.push(`|  Version:    ${(manifest.manifestVersion || 'N/A').padEnd(43)}|`);
  lines.push(`|  Type:       ${(isFragment ? 'Fragment' : 'Full Manifest').padEnd(43)}|`);
  lines.push(`|  Routes:     ${`${sectionCount} sections, ${pageCount} pages, ${refCount} fragment refs`.padEnd(43)}|`);
  lines.push(`|  Components: ${`${componentCount} instances (${uniqueAliases.size} unique aliases)`.padEnd(43)}|`);
  lines.push(`|  Plugins:    ${`${pluginCount} (${urlPlugins} URL, ${settingPlugins} setting)`.padEnd(43)}|`);
  lines.push('+----------------------------------------------------------+');
  lines.push(`|  CRITICAL: ${issues.critical.length}  |  HIGH: ${issues.high.length}  |  MEDIUM: ${issues.medium.length}  |  LOW: ${issues.low.length}`.padEnd(59) + '|');
  lines.push('+----------------------------------------------------------+');
  lines.push('');

  // Sort: CRITICAL > HIGH > MEDIUM > LOW > INFO
  const severityOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4 };
  const sorted = [...issues.items].sort((a, b) => (severityOrder[a.severity] || 5) - (severityOrder[b.severity] || 5));

  for (const issue of sorted) {
    lines.push(`${issue.severity} [${issue.ruleId}] ${issue.message}`);
    lines.push(`  at ${issue.path}`);
    if (issue.fix && opts.fixSuggestions) {
      lines.push(`  > ${issue.fix}`);
    }
    lines.push('');
  }

  // Summary
  const blocking = issues.blocking.length;
  if (blocking > 0) {
    lines.push(`RESULT: FAIL (${blocking} blocking issues — ${issues.critical.length} CRITICAL, ${issues.high.length} HIGH)`);
    lines.push('');
    lines.push('Fix the CRITICAL and HIGH issues above, then re-run validation.');
    if (!opts.strict) {
      lines.push('MEDIUM and LOW issues are recommendations (enforce with --strict).');
    }
  } else {
    lines.push(`RESULT: PASS (0 CRITICAL, 0 HIGH issues)`);
    lines.push('');
    lines.push('Manifest is structurally valid and ready for deployment.');
    if (issues.medium.length + issues.low.length > 0) {
      lines.push(`${issues.medium.length} MEDIUM and ${issues.low.length} LOW recommendations noted above.`);
    }
  }

  return lines.join('\n');
}

function formatJSON(file, manifest, issues) {
  return JSON.stringify({
    file: basename(file),
    manifestVersion: manifest.manifestVersion,
    result: issues.blocking.length > 0 ? 'FAIL' : 'PASS',
    counts: {
      critical: issues.critical.length,
      high: issues.high.length,
      medium: issues.medium.length,
      low: issues.low.length,
      info: issues.info.length,
    },
    issues: issues.items.map(i => ({
      severity: i.severity,
      ruleId: i.ruleId,
      message: i.message,
      path: i.path,
      fix: i.fix,
    })),
  }, null, 2);
}

// ─── Main ───────────────────────────────────────────────────────────────────
function validate(manifestPath, opts = {}) {
  const content = readFileSync(manifestPath, 'utf8');
  let manifest;
  try {
    manifest = JSON.parse(content);
  } catch (e) {
    console.error(`ERROR: Invalid JSON in ${manifestPath}: ${e.message}`);
    return { result: 'ERROR', issues: new Issues() };
  }

  const isFragment = !manifest.name && !manifest.homePath && manifest.manifestVersion === '2.0';
  const issues = new Issues();

  // Allow callers to whitelist custom plugin-registered template helpers
  _customHelpers = new Set(Array.isArray(opts.customHelpers) ? opts.customHelpers : []);

  phase1(manifest, issues, isFragment);
  phase2(manifest, issues);
  phase3(manifest, issues);
  phase4(manifest, issues);
  phase5(manifest, issues, opts.component);
  phase6(manifest, issues);

  return { manifest, issues, isFragment };
}

// ─── Exports (for unit testing) ─────────────────────────────────────────────
export { validate, Issues, phase1, phase2, phase3, phase4, phase5, phase6, KNOWN_ALIASES, KNOWN_HELPERS, LEGACY_ALIASES };

// ─── CLI Entry ──────────────────────────────────────────────────────────────
// Only run CLI when executed directly (not when imported for testing)
const __isDirectRun = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (!__isDirectRun) {
  // Imported as module — skip CLI
} else {

const args = process.argv.slice(2);
if (args.length === 0 || args.includes('--help')) {
  console.log(`Mystique Manifest Validator v1.0

Usage: node validator.mjs <manifest.json|directory> [options]

Options:
  --strict            Treat MEDIUM/LOW as errors
  --fix-suggestions   Include fix suggestions in output
  --component <alias> Validate only instances of specified component
  --json              Output as JSON instead of text
  --output <file>     Write report to file
  --help              Show this help`);
  process.exit(0);
}

const inputPath = resolve(args[0]);
const opts = {
  strict: args.includes('--strict'),
  fixSuggestions: args.includes('--fix-suggestions'),
  json: args.includes('--json'),
  component: null,
  output: null,
};

const compIdx = args.indexOf('--component');
if (compIdx !== -1 && args[compIdx + 1]) opts.component = args[compIdx + 1];

const outIdx = args.indexOf('--output');
if (outIdx !== -1 && args[outIdx + 1]) opts.output = args[outIdx + 1];

// Always show fix suggestions in report output
opts.fixSuggestions = true;

let files;
try {
  files = discoverManifests(inputPath);
} catch (e) {
  console.error(`ERROR: Cannot access "${inputPath}": ${e.message}`);
  process.exit(1);
}

if (files.length === 0) {
  console.error(`No Mystique manifest files found at "${inputPath}"`);
  process.exit(1);
}

let totalBlocking = 0;
const allResults = [];

for (const file of files) {
  const { manifest, issues, isFragment } = validate(file, opts);
  if (!manifest) {
    totalBlocking++;
    continue;
  }

  if (opts.json) {
    allResults.push(JSON.parse(formatJSON(file, manifest, issues)));
  } else {
    const report = formatReport(file, manifest, issues, opts);
    console.log(report);
    console.log('');
  }

  if (opts.strict) {
    totalBlocking += issues.items.filter(i => ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].includes(i.severity)).length;
  } else {
    totalBlocking += issues.blocking.length;
  }
}

if (opts.json) {
  const out = files.length === 1 ? allResults[0] : allResults;
  const jsonStr = JSON.stringify(out, null, 2);
  if (opts.output) {
    writeFileSync(opts.output, jsonStr, 'utf8');
    console.log(`Report written to ${opts.output}`);
  } else {
    console.log(jsonStr);
  }
} else if (opts.output) {
  // Re-run to capture output (simple approach)
  console.log(`Note: Use --json --output for file output`);
}

process.exit(totalBlocking > 0 ? 1 : 0);

} // end __isDirectRun guard
