#!/usr/bin/env node

/**
 * Validates a Smart Canvas config JSON against the canvas-config JSON Schema.
 *
 * Uses Ajv for proper JSON Schema 2020-12 validation including $ref, oneOf, const, etc.
 * Reads canvas-config.schema.json (the generated unified schema), falling back to
 * canvas-config.base.schema.json if the unified version hasn't been built yet.
 *
 * Usage: npm run validate-config -- <path/to/config.json>
 */

import Ajv from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import fs from 'fs';
import path from 'path';
import url from 'url';

const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const schemaDir = path.resolve(__dirname, '../schema');
const unifiedPath = path.join(schemaDir, 'canvas-config.schema.json');
const basePath = path.join(schemaDir, 'canvas-config.base.schema.json');

const usage = () => {
  console.error('Usage: npm run validate-config -- <path/to/config.json>');
};

const targetPath = process.argv[2];
if (!targetPath) {
  usage();
  process.exit(1);
}

// Load the best available schema (unified > base)
let schemaPath;
if (fs.existsSync(unifiedPath)) {
  schemaPath = unifiedPath;
  console.log('Using unified schema (core + adaptive action kinds)');
} else if (fs.existsSync(basePath)) {
  schemaPath = basePath;
  console.log('Using base schema (run npm run generate-schema to include adaptive actions)');
} else {
  console.error('No schema found. Run npm run generate-schema first.');
  process.exit(1);
}

let schema;
try {
  schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
} catch (err) {
  console.error(`Failed to read schema ${schemaPath}:`, err.message);
  process.exit(1);
}

// Load the config to validate
const configPath = path.resolve(process.cwd(), targetPath);
let config;
try {
  config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch (err) {
  console.error(`Failed to read config ${configPath}:`, err.message);
  process.exit(1);
}

// Compile and validate with Ajv
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);

const validate = ajv.compile(schema);
const valid = validate(config);

if (!valid) {
  console.error(`\u274C Config invalid (${validate.errors.length} issue(s)):`);
  for (const err of validate.errors) {
    const location = err.instancePath || '(root)';
    console.error(
      `  - ${location}: ${err.message}${err.params ? ` ${JSON.stringify(err.params)}` : ''}`
    );
  }
  process.exit(1);
}

console.log(`\u2705 ${path.relative(process.cwd(), configPath)} is valid.`);

// --- Semantic checks (cross-field validation JSON Schema can't express) ---
const WIDGET_EVENT_MAP = {
  'adaptive-faq:accordion': 'faq:question_revealed',
  'adaptive-nav:tips': 'nav:tip_revealed',
};

const semanticErrors = [];
for (const tile of config.tiles ?? []) {
  const expectedEvent = WIDGET_EVENT_MAP[tile.widget];
  if (!expectedEvent) continue;

  const actions = tile.props?.actions ?? [];
  const notifyActions = actions.filter((a) => a.notify && a.triggerWhen);
  if (notifyActions.length === 0) continue;

  // Check tile has a notifications rule matching the expected event
  const rules = tile.notifications ?? [];
  const hasMatchingRule = rules.some((r) => r.on === expectedEvent);
  if (hasMatchingRule) continue;

  for (const a of notifyActions) {
    const actionId = a.config?.id ?? '(unknown)';
    semanticErrors.push(
      `Tile "${tile.id}": action "${actionId}" has notify + triggerWhen ` +
        `but tile is missing a notifications rule for "${expectedEvent}".\n` +
        `    Toast will not render. Add to tile.notifications:\n` +
        `    {"on": "${expectedEvent}", "title": "{{props.title}}", "body": "{{props.body}}"}`
    );
  }
}

if (semanticErrors.length > 0) {
  console.error(`\n\u274C ${semanticErrors.length} notification wiring error(s):`);
  for (const e of semanticErrors) console.error(`  - ${e}`);
  process.exit(1);
}

// --- Semantic check: tiles with notifications must have actions ---
// Notifications reference content (FAQ items, nav tips) that lives in
// tile.props.actions. Without actions, notifications have nothing to show.
for (const tile of config.tiles ?? []) {
  const notifications = tile.notifications ?? [];
  if (notifications.length === 0) continue;

  const actions = tile.props?.actions ?? [];
  if (actions.length === 0) {
    semanticErrors.push(
      `Tile "${tile.id}": has ${notifications.length} notification(s) but no actions.\n` +
        `    Notifications reference tile content (FAQ items, nav tips) defined in tile.props.actions.\n` +
        `    Add actions to the tile, or remove the notifications.`
    );
  }
}

if (semanticErrors.length > 0) {
  console.error(`\n\u274C ${semanticErrors.length} semantic error(s):`);
  for (const e of semanticErrors) console.error(`  - ${e}`);
  process.exit(1);
}

// --- Semantic check: anchorId.route must include at least one concrete route ---
// Wildcards allowed by canvas-config.schema.json anchorId.route;
// update here if the schema adds new glob patterns.
const WILDCARD_PATTERNS = ['**', '*'];

function isWildcardOnly(route) {
  if (typeof route === 'string') return WILDCARD_PATTERNS.includes(route);
  if (Array.isArray(route)) return route.every((r) => WILDCARD_PATTERNS.includes(r));
  return false;
}

const routeErrors = [];
for (const [i, action] of (config.actions ?? []).entries()) {
  const anchorId = action.anchorId;
  if (!anchorId || typeof anchorId !== 'object') continue;
  const route = anchorId.route;
  if (route == null) continue;
  if (isWildcardOnly(route)) {
    const selector = anchorId.selector ?? '(unknown)';
    const kind = action.kind ?? '(unknown)';
    const routeDisplay = Array.isArray(route)
      ? route.length === 0
        ? '(empty array)'
        : route.join('", "')
      : route;
    routeErrors.push(
      `actions[${i}] (kind: "${kind}", selector: "${selector}"): ` +
        `anchorId.route is wildcard-only ("${routeDisplay}").\n` +
        `    anchorId.route must include at least one concrete route (e.g., "/dashboard/income").\n` +
        `    Wildcards like "**" may be included alongside concrete routes for broad matching,\n` +
        `    but the editor needs a concrete route to enable page navigation.`
    );
  }
}

if (routeErrors.length > 0) {
  console.error(`\n\u274C ${routeErrors.length} anchorId.route error(s):`);
  for (const e of routeErrors) console.error(`  - ${e}`);
  process.exit(1);
}

// --- Semantic warnings (non-blocking, design-quality hints) ---
const warnings = [];

// Tile-level route restrictions should be rare. They hide the entire tile
// (and all its actions) on non-matching pages. The preferred pattern is:
//   - Tile: routes ["/**"] (or omitted) + onlyIfPopulated: true
//   - Actions: triggerWhen with route conditions to self-target specific pages
// This way the tile is always available and actions decide when to appear.
for (const tile of config.tiles ?? []) {
  const routes = tile.activation?.routes;
  if (!routes) continue;

  const includePatterns = routes.include ?? [];
  const hasCatchAll = includePatterns.some((r) => WILDCARD_PATTERNS.includes(r) || r === '/**');
  if (hasCatchAll) continue;

  const routeList = includePatterns.join('", "');
  const actions = tile.props?.actions ?? [];
  const actionsWithoutTrigger = actions.filter((a) => !a.triggerWhen).length;

  let detail = '';
  if (actionsWithoutTrigger > 0) {
    detail =
      `\n    ${actionsWithoutTrigger} of ${actions.length} action(s) have no triggerWhen — ` +
      `they'll never appear outside ["${routeList}"].`;
  }

  warnings.push(
    `Tile "${tile.id}": activation.routes restricts to ["${routeList}"].${detail}\n` +
      `    Prefer: set routes to ["/**"], add onlyIfPopulated: true, and use ` +
      `triggerWhen with a "route" condition on each action to target specific pages.`
  );
}

// Celebrate actions without triggerWhen fire on every page load — almost always a mistake.
// The effect (confetti, etc.) should be scoped to a specific page or user action.
for (const [i, action] of (config.actions ?? []).entries()) {
  if (action.kind !== 'overlays:celebrate') continue;
  if (action.triggerWhen) continue;

  const label = action.label ? ` (label: "${action.label}")` : '';
  warnings.push(
    `actions[${i}]${label}: overlays:celebrate has no triggerWhen.\n` +
      `    This fires the "${action.effect ?? 'confetti'}" effect on EVERY page load.\n` +
      `    Add a triggerWhen with a page_url condition to scope it, e.g.:\n` +
      `    "triggerWhen": {"type":"rules","rules":[{"conditions":[{"type":"page_url","url":"**/target-page"}],"value":true}],"default":false}`
  );
}

// page_url conditions should use ** prefix to match any host, not absolute paths.
// "/dashboard/state" won't match "http://localhost:8088/dashboard/state" because
// page_url evaluates against the full URL. Use "**/dashboard/state" instead.
function collectPageUrlConditions(obj, path, results) {
  if (!obj || typeof obj !== 'object') return;
  if (Array.isArray(obj)) {
    for (const [i, item] of obj.entries()) {
      collectPageUrlConditions(item, `${path}[${i}]`, results);
    }
    return;
  }
  if (obj.type === 'page_url' && typeof obj.url === 'string') {
    results.push({ path, url: obj.url });
    return;
  }
  for (const [key, val] of Object.entries(obj)) {
    collectPageUrlConditions(val, `${path}.${key}`, results);
  }
}

const pageUrlHits = [];
collectPageUrlConditions(config, 'config', pageUrlHits);
for (const { path, url } of pageUrlHits) {
  if (url.startsWith('/')) {
    warnings.push(
      `${path}: page_url "${url}" uses an absolute path.\n` +
        `    page_url matches against the full URL (e.g., "http://localhost:8088${url}"),\n` +
        `    so "${url}" will never match. Use "**${url}" instead.`
    );
  }
}

// Hidden insertHtml without a triggerWhen is dead HTML — the SDK has no way to show it.
// The LLM sometimes generates `display:none` elements expecting a trigger type (e.g.,
// exit-intent) that doesn't exist in the SDK. Without triggerWhen, the element is
// injected hidden and stays hidden forever.
for (const [i, action] of (config.actions ?? []).entries()) {
  if (action.kind !== 'content:insertHtml') continue;
  if (action.triggerWhen) continue;
  const html = action.html ?? '';
  if (!/display\s*:\s*none/i.test(html)) continue;

  const label = action.label ? ` (label: "${action.label}")` : '';
  warnings.push(
    `actions[${i}]${label}: content:insertHtml injects hidden HTML (display:none) with no triggerWhen.\n` +
      `    This element will be inserted into the DOM but never shown — the SDK has no mechanism to reveal it.\n` +
      `    Either add a triggerWhen with a valid condition (e.g., event_count, session_metric) to control visibility,\n` +
      `    or remove display:none to show it immediately.`
  );
}

if (warnings.length > 0) {
  console.warn(`\n\u26A0\uFE0F  ${warnings.length} design warning(s):`);
  for (const w of warnings) console.warn(`  - ${w}`);
}

// --- Display meta info (non-blocking, informational) ---
const meta = config.meta;
if (meta) {
  if (meta.name) console.log(`\n\uD83D\uDCCB  ${meta.name}`);
  if (meta.description) console.log(`   ${meta.description}`);
  if (meta.trigger) console.log(`   Trigger: ${meta.trigger}`);
  if (meta.verificationSteps?.length > 0) {
    console.log(`\n\uD83E\uDDEA  Verification steps:`);
    for (const [i, step] of meta.verificationSteps.entries()) {
      const text = typeof step === 'string' ? step : step.text;
      const check = typeof step === 'object' && step.check ? ` [auto: ${step.check.type}]` : '';
      console.log(`   ${i + 1}. ${text}${check}`);
    }
  }
}
