#!/usr/bin/env node
/*
 * Runtime chart guard for the sales analytics pages.
 *
 * It catches the failure mode where the page renders a chart shell but Chart.js
 * never draws the real series. The check intentionally inspects both the
 * Chart.js runtime objects and the actual canvas pixels.
 */

const fs = require('fs');
const os = require('os');
const path = require('path');
const Module = require('module');

const DEFAULT_BASE_URL = 'http://127.0.0.1:5003';
const DEFAULT_TIMEOUT_MS = 30000;
const DEFAULT_SETTLE_MS = 1800;

const ROUTE_CONTRACTS = {
  '/daily_sales': {
    readyDataset: 'dailyCharts',
    expectedCanvases: ['trendChart', 'dodChart', 'wowChart', 'top10Chart'],
  },
  '/growth_analysis': {
    readyDataset: 'growthCharts',
    expectedCanvases: ['revenueChart', 'momChart', 'aovChart', 'marginChart'],
  },
};

function parseArgs(argv) {
  const options = {
    baseUrl: DEFAULT_BASE_URL,
    routes: [],
    timeoutMs: DEFAULT_TIMEOUT_MS,
    settleMs: DEFAULT_SETTLE_MS,
    json: false,
    screenshotDir: '',
  };

  for (let i = 0; i < argv.length; i += 1) {
    const arg = argv[i];
    if (arg === '--base-url') {
      options.baseUrl = argv[++i];
    } else if (arg === '--route') {
      options.routes.push(argv[++i]);
    } else if (arg === '--routes') {
      options.routes.push(...argv[++i].split(',').map((item) => item.trim()).filter(Boolean));
    } else if (arg === '--timeout') {
      options.timeoutMs = Number(argv[++i]) * 1000;
    } else if (arg === '--settle-ms') {
      options.settleMs = Number(argv[++i]);
    } else if (arg === '--screenshot-dir') {
      options.screenshotDir = argv[++i];
    } else if (arg === '--json') {
      options.json = true;
    } else if (arg === '--help' || arg === '-h') {
      printHelp();
      process.exit(0);
    } else {
      throw new Error(`Unknown argument: ${arg}`);
    }
  }

  if (options.routes.length === 0) {
    options.routes = Object.keys(ROUTE_CONTRACTS);
  }
  return options;
}

function printHelp() {
  console.log(`Sales charts runtime guard

Options:
  --base-url URL          Base URL, default ${DEFAULT_BASE_URL}
  --route PATH            Add one route, can be repeated
  --routes A,B,C          Add comma-separated routes
  --timeout SEC           Navigation timeout, default 30
  --settle-ms MS          Post-ready settle wait, default 1800
  --screenshot-dir DIR    Save failure screenshots
  --json                  Print JSON summary
`);
}

function requirePlaywright() {
  try {
    return require('playwright');
  } catch (error) {
    const candidates = [
      process.env.PLAYWRIGHT_NODE_MODULE_DIR,
      process.env.NODE_PATH,
      path.join(os.homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'),
    ].filter(Boolean);

    for (const candidate of candidates) {
      const packageJson = path.join(candidate, 'playwright', 'package.json');
      if (fs.existsSync(packageJson)) {
        return Module.createRequire(packageJson)('playwright');
      }
    }

    throw new Error('Cannot load playwright. Set NODE_PATH or PLAYWRIGHT_NODE_MODULE_DIR to a node_modules directory containing playwright.');
  }
}

function findChromeExecutable() {
  const candidates = [
    process.env.SALES_CHARTS_CHROME_PATH,
    '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
    '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
    '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
  ].filter(Boolean);
  return candidates.find((candidate) => fs.existsSync(candidate)) || '';
}

function routeToUrl(baseUrl, route) {
  return new URL(route, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
}

function safeName(input) {
  return input.replace(/[^a-z0-9]+/gi, '_').replace(/^_+|_+$/g, '').toLowerCase() || 'root';
}

async function collectChartMetrics(page, contract) {
  return page.evaluate(({ expectedCanvases, readyDataset }) => {
    function countCanvasPixels(canvas) {
      const context = canvas.getContext('2d', { willReadFrequently: true });
      if (!context || !canvas.width || !canvas.height) return { inkPixels: 0, colorPixels: 0 };

      const sampleWidth = Math.min(canvas.width, 900);
      const sampleHeight = Math.min(canvas.height, 520);
      const pixels = context.getImageData(0, 0, sampleWidth, sampleHeight).data;
      let ink = 0;
      let color = 0;
      for (let i = 0; i < pixels.length; i += 4) {
        const alpha = pixels[i + 3];
        if (alpha > 10) {
          ink += 1;
          const r = pixels[i];
          const g = pixels[i + 1];
          const b = pixels[i + 2];
          const channelSpread = Math.max(r, g, b) - Math.min(r, g, b);
          if (alpha > 24 && channelSpread > 18) color += 1;
        }
      }
      return { inkPixels: ink, colorPixels: color };
    }

    function datasetStats(chart) {
      if (!chart || !chart.data || !Array.isArray(chart.data.datasets)) {
        return { dataPoints: 0, nonZeroPoints: 0, min: null, max: null };
      }
      const values = [];
      chart.data.datasets.forEach((dataset) => {
        const data = Array.isArray(dataset.data) ? dataset.data : [];
        data.forEach((value) => {
          const number = Number(value);
          if (value !== null && value !== undefined && Number.isFinite(number)) values.push(number);
        });
      });
      return {
        dataPoints: values.length,
        nonZeroPoints: values.filter(value => Math.abs(value) > 1e-9).length,
        min: values.length ? Math.min(...values) : null,
        max: values.length ? Math.max(...values) : null,
      };
    }

    function visibleElementCount(chart) {
      if (!chart || typeof chart.getSortedVisibleDatasetMetas !== 'function') return 0;
      return chart.getSortedVisibleDatasetMetas().reduce((total, meta) => {
        if (!meta || !Array.isArray(meta.data)) return total;
        return total + meta.data.filter((item) => {
          if (!item) return false;
          const props = typeof item.getProps === 'function'
            ? item.getProps(['x', 'y', 'base'], true)
            : item;
          return Number.isFinite(Number(props.x)) && Number.isFinite(Number(props.y));
        }).length;
      }, 0);
    }

    const chartGetter = window.Chart && typeof window.Chart.getChart === 'function'
      ? window.Chart.getChart.bind(window.Chart)
      : () => null;

    const canvases = expectedCanvases.map((id) => {
      const canvas = document.getElementById(id);
      if (!canvas) return { id, exists: false };

      const rect = canvas.getBoundingClientRect();
      const chart = chartGetter(canvas);
      const pixels = countCanvasPixels(canvas);
      const stats = datasetStats(chart);
      return {
        id,
        exists: true,
        cssWidth: Math.round(rect.width),
        cssHeight: Math.round(rect.height),
        width: canvas.width,
        height: canvas.height,
        inkPixels: pixels.inkPixels,
        colorPixels: pixels.colorPixels,
        hasChart: Boolean(chart),
        datasetCount: chart && chart.data && Array.isArray(chart.data.datasets) ? chart.data.datasets.length : 0,
        dataPoints: stats.dataPoints,
        nonZeroPoints: stats.nonZeroPoints,
        min: stats.min,
        max: stats.max,
        visibleElements: visibleElementCount(chart),
        ariaHidden: canvas.getAttribute('aria-hidden') || '',
      };
    });

    return {
      finalUrl: location.href,
      title: document.title,
      login: Boolean(document.querySelector('form[action="/login"]')),
      readyState: document.documentElement.dataset[readyDataset] || '',
      chartVersion: window.Chart && window.Chart.version ? window.Chart.version : '',
      canvases,
    };
  }, { expectedCanvases: contract.expectedCanvases, readyDataset: contract.readyDataset });
}

function evaluateMetrics(route, status, metrics, consoleErrors) {
  const failures = [];
  if (!status || status >= 400) failures.push(`HTTP ${status || 0}`);
  if (metrics.login) failures.push('redirected to login');
  if (metrics.readyState !== 'ready') failures.push(`chart boot state is ${metrics.readyState || 'missing'}`);
  if (!metrics.chartVersion) failures.push('Chart.js runtime missing');
  for (const item of metrics.canvases) {
    if (!item.exists) {
      failures.push(`${item.id} missing`);
      continue;
    }
    if (item.cssWidth < 220 || item.cssHeight < 180) {
      failures.push(`${item.id} too small ${item.cssWidth}x${item.cssHeight}`);
    }
    if (!item.hasChart) failures.push(`${item.id} has no Chart.js instance`);
    if (item.datasetCount <= 0 || item.dataPoints <= 0) {
      failures.push(`${item.id} has no drawable dataset`);
    }
    if (item.nonZeroPoints <= 0) {
      failures.push(`${item.id} has no non-zero dataset values`);
    }
    if (item.visibleElements <= 0) failures.push(`${item.id} has no visible chart elements`);
    if (item.inkPixels <= 900) failures.push(`${item.id} canvas appears blank`);
    if (item.colorPixels <= 40) failures.push(`${item.id} has no colored chart marks`);
  }
  for (const err of consoleErrors) {
    failures.push(`console ${err}`);
  }
  return {
    route,
    status,
    passed: failures.length === 0,
    failures,
    metrics,
  };
}

async function inspectRoute(page, options, route) {
  const contract = ROUTE_CONTRACTS[route];
  if (!contract) throw new Error(`No chart contract for route: ${route}`);

  const consoleErrors = [];
  const onConsole = (message) => {
    if (message.type() === 'error') consoleErrors.push(message.text());
  };
  const onPageError = (error) => consoleErrors.push(error.message || String(error));
  page.on('console', onConsole);
  page.on('pageerror', onPageError);

  try {
    const response = await page.goto(routeToUrl(options.baseUrl, route), {
      waitUntil: 'domcontentloaded',
      timeout: options.timeoutMs,
    });
    await page.waitForFunction(
      (readyDataset) => document.documentElement.dataset[readyDataset] === 'ready',
      contract.readyDataset,
      { timeout: Math.min(options.timeoutMs, 15000) },
    ).catch(() => null);
    if (options.settleMs > 0) await page.waitForTimeout(options.settleMs);

    const metrics = await collectChartMetrics(page, contract);
    return evaluateMetrics(route, response ? response.status() : 0, metrics, consoleErrors);
  } finally {
    page.off('console', onConsole);
    page.off('pageerror', onPageError);
  }
}

async function main() {
  const options = parseArgs(process.argv.slice(2));
  const { chromium } = requirePlaywright();
  const launchOptions = { headless: true };
  const chromePath = findChromeExecutable();
  if (chromePath) launchOptions.executablePath = chromePath;

  if (options.screenshotDir) fs.mkdirSync(options.screenshotDir, { recursive: true });

  const browser = await chromium.launch(launchOptions);
  const page = await browser.newPage({
    viewport: { width: 1440, height: 1000 },
    deviceScaleFactor: 1,
  });
  const results = [];

  try {
    for (const route of options.routes) {
      const result = await inspectRoute(page, options, route);
      results.push(result);
      if (!result.passed && options.screenshotDir) {
        await page.screenshot({
          path: path.join(options.screenshotDir, `${safeName(route)}.png`),
          fullPage: false,
        });
      }
    }
  } finally {
    await page.close().catch(() => {});
    await browser.close();
  }

  if (options.json) {
    console.log(JSON.stringify(results, null, 2));
  } else {
    for (const result of results) {
      const canvasSummary = result.metrics.canvases
        .map((item) => `${item.id}:chart=${item.hasChart ? 'yes' : 'no'},points=${item.dataPoints || 0},nonzero=${item.nonZeroPoints || 0},ink=${item.inkPixels || 0},color=${item.colorPixels || 0}`)
        .join(' ');
      console.log(`${result.passed ? 'PASS' : 'FAIL'} ${result.route} status=${result.status} ${canvasSummary}`);
      for (const failure of result.failures) {
        console.log(`  ${failure}`);
      }
    }
  }

  if (results.some((result) => !result.passed)) {
    process.exitCode = 1;
  }
}

main().catch((error) => {
  console.error(error.message || error);
  process.exit(1);
});
