import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdir, writeFile } from 'node:fs/promises';
import { createServer as createNetServer } from 'node:net';
import path from 'node:path';
import {
  cleanupBrowserTmp,
  openBrowser,
  safeCloseBrowser,
  setupBrowserTmp,
} from './lib/browser.mjs';
import { discoverRoutes, requireDist } from './lib/dist.mjs';

const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
const outputJson = path.resolve(
  process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
);
const outputMarkdown = path.resolve(
  process.env.ASTRO_AUDIT_OUTPUT_MD ?? '.astro/astro-audit-results.md'
);
const astroBin = path.resolve(
  'node_modules',
  '.bin',
  process.platform === 'win32' ? 'astro.cmd' : 'astro'
);
const HOST = '127.0.0.1';
const SERVER_START_TIMEOUT_MS = 60000;
const CLOSE_TIMEOUT_MS = 3000;
const NAV_TIMEOUT_MS = 20000;
const AUDIT_TIMEOUT_MS = 30000;
const DEFAULT_VIEWPORTS = '1440x900';
const failOnIssues =
  process.argv.includes('--fail-on-issues') ||
  process.env.ASTRO_AUDIT_FAIL_ON_ISSUES === '1';

// Heuristic above-fold / below-fold loading rules flip based on the heights of
// items rendered above them, which shift whenever a post's description length
// changes. They produce false positives that can't be resolved with a single
// `eagerThumbnailCount` per list, so the audit suppresses them.
const IGNORED_AUDIT_CODES = new Set(['perf-use-loading-eager', 'perf-use-loading-lazy']);

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWPORTS) {
  return raw.split(',').map((entry) => {
    const match = entry.trim().match(/^(\d+)x(\d+)$/i);
    if (!match) {
      throw new Error(
        `Invalid ASTRO_AUDIT_VIEWPORTS entry "${entry}". Use values like 1440x900,390x844.`
      );
    }
    return { width: Number(match[1]), height: Number(match[2]) };
  });
}

async function discoverAuditRoutes() {
  if (process.env.ASTRO_AUDIT_ROUTES) {
    return process.env.ASTRO_AUDIT_ROUTES.split(',')
      .map((route) => route.trim())
      .filter(Boolean)
      .map((route) => (route.startsWith('/') ? route : `/${route}`));
  }

  await requireDist();
  return discoverRoutes();
}

async function getFreePort() {
  const server = createNetServer();
  await new Promise((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, HOST, resolve);
  });
  const { port } = server.address();
  await new Promise((resolve, reject) => {
    server.close((error) => (error ? reject(error) : resolve()));
  });
  return port;
}

function startAstroDev(port) {
  const child = spawn(astroBin, ['dev', '--host', HOST, '--port', String(port)], {
    stdio: ['ignore', 'pipe', 'pipe'],
    env: {
      ...process.env,
      ASTRO_TELEMETRY_DISABLED: '1',
      NO_COLOR: '1',
      TMPDIR: browserTmp,
      TMP: browserTmp,
      TEMP: browserTmp,
    },
  });

  let output = '';
  const append = (chunk) => {
    output = `${output}${chunk.toString()}`.slice(-20000);
  };
  child.stdout.on('data', append);
  child.stderr.on('data', append);
  child.getRecentOutput = () => output.trim();
  return child;
}

async function waitForDevServer(baseUrl, child) {
  const deadline = Date.now() + SERVER_START_TIMEOUT_MS;

  while (Date.now() < deadline) {
    if (child.exitCode !== null) {
      throw new Error(
        `Astro dev server exited before it was ready.\n${child.getRecentOutput()}`
      );
    }

    try {
      const response = await fetch(baseUrl);
      if (response.status < 500) return;
    } catch {
      // Server is not listening yet.
    }

    await sleep(250);
  }

  throw new Error(
    `Timed out waiting for Astro dev server at ${baseUrl}.\n${child.getRecentOutput()}`
  );
}

async function stopProcess(child) {
  if (!child || child.exitCode !== null) return;
  child.kill('SIGTERM');
  const exited = once(child, 'exit');
  const timedOut = sleep(CLOSE_TIMEOUT_MS).then(() => 'timeout');
  if ((await Promise.race([exited, timedOut])) === 'timeout') {
    child.kill('SIGKILL');
  }
}

function viewportLabel(viewport) {
  return `${viewport.width}x${viewport.height}`;
}

async function extractAuditResults(page) {
  return page.evaluate(
    async ({ auditTimeoutMs }) => {
      const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
      const deadline = Date.now() + auditTimeoutMs;

      function describeElement(element) {
        if (!element) return '';
        const pieces = [];
        let current = element;
        while (
          current instanceof Element &&
          current !== document.documentElement &&
          pieces.length < 5
        ) {
          let piece = current.localName;
          if (current.id) {
            piece += `#${current.id}`;
          } else {
            const classes = [...current.classList].slice(0, 2);
            if (classes.length > 0) piece += `.${classes.join('.')}`;
            const parent = current.parentElement;
            if (parent) {
              const siblings = [...parent.children].filter(
                (child) => child.localName === current.localName
              );
              if (siblings.length > 1) {
                piece += `:nth-of-type(${siblings.indexOf(current) + 1})`;
              }
            }
          }
          pieces.unshift(piece);
          current = current.parentElement;
        }
        return pieces.join(' > ');
      }

      function resolveRuleValue(value, element) {
        try {
          if (typeof value === 'function') return String(value(element) ?? '');
          return String(value ?? '');
        } catch (error) {
          return `Error resolving audit value: ${
            error instanceof Error ? error.message : String(error)
          }`;
        }
      }

      function sourceForAudit(audit) {
        const tooltip = audit.highlight?.shadowRoot?.querySelector(
          'astro-dev-toolbar-tooltip'
        );
        const sourceSection = tooltip?.sections?.find(
          (section) => section.clickDescription === 'Click to go to file'
        );
        return sourceSection?.content ?? null;
      }

      while (Date.now() < deadline) {
        const toolbar = document.querySelector('astro-dev-toolbar');
        const button = toolbar?.shadowRoot?.querySelector('[data-app-id="astro:audit"]');

        if (button && !button.classList.contains('active')) {
          button.click();
        }

        const auditCanvas = toolbar?.shadowRoot?.querySelector(
          'astro-dev-toolbar-app-canvas[data-app-id="astro:audit"]'
        );
        const auditWindow = auditCanvas?.shadowRoot?.querySelector(
          'astro-dev-toolbar-audit-window'
        );

        if (button?.classList.contains('active') && auditWindow) {
          await sleep(100);
          return {
            results: (auditWindow.audits ?? []).map((audit) => {
              const element = audit.auditedElement;
              return {
                code: audit.rule.code,
                category: audit.rule.code?.split('-')[0] ?? '',
                title: resolveRuleValue(audit.rule.title, element),
                message: resolveRuleValue(audit.rule.message, element),
                description: resolveRuleValue(audit.rule.description, element),
                ruleSelector: audit.rule.selector,
                source: sourceForAudit(audit),
                element: {
                  selector: describeElement(element),
                  tagName: element?.tagName?.toLowerCase() ?? '',
                  html: element?.outerHTML?.replace(/\s+/g, ' ').slice(0, 800) ?? '',
                },
              };
            }),
          };
        }

        await sleep(250);
      }

      return {
        error:
          'Timed out waiting for the Astro Dev Toolbar Audit app. Make sure devToolbar is enabled.',
      };
    },
    { auditTimeoutMs: AUDIT_TIMEOUT_MS }
  );
}

async function auditRoute(context, baseUrl, route, viewport) {
  const page = await context.newPage();
  try {
    await page.goto(new URL(route, baseUrl).href, {
      waitUntil: 'domcontentloaded',
      timeout: NAV_TIMEOUT_MS,
    });
    await page.waitForSelector('astro-dev-toolbar', {
      state: 'attached',
      timeout: AUDIT_TIMEOUT_MS,
    });
    await page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});

    const audit = await extractAuditResults(page);
    if (audit.error) {
      throw new Error(`${route} at ${viewportLabel(viewport)}: ${audit.error}`);
    }

    return audit.results
      .filter((result) => !IGNORED_AUDIT_CODES.has(result.code))
      .map((result) => ({
        route,
        url: page.url(),
        viewport: viewportLabel(viewport),
        ...result,
      }));
  } finally {
    await page.close().catch(() => {});
  }
}

function markdownEscape(value) {
  return String(value ?? '').replaceAll('|', '\\|');
}

function renderMarkdown(report) {
  const lines = [
    '# Astro Audit Results',
    '',
    `Generated: ${report.generatedAt}`,
    `Routes checked: ${report.routes.length}`,
    `Viewports: ${report.viewports.map(viewportLabel).join(', ')}`,
    `Issues found: ${report.results.length}`,
    '',
  ];

  if (report.results.length === 0) {
    lines.push('No accessibility or performance issues detected.');
    return `${lines.join('\n')}\n`;
  }

  const byRoute = Map.groupBy(report.results, (result) => result.route);
  for (const [route, routeResults] of byRoute) {
    lines.push(`## ${route}`, '');
    lines.push('| Viewport | Code | Title | Source | Element |');
    lines.push('| --- | --- | --- | --- | --- |');
    for (const result of routeResults) {
      lines.push(
        `| ${markdownEscape(result.viewport)} | \`${markdownEscape(result.code)}\` | ${markdownEscape(
          result.title
        )} | ${markdownEscape(result.source ?? '')} | \`${markdownEscape(
          result.element.selector
        )}\` |`
      );
    }
    lines.push('');
  }

  lines.push('## Details', '');
  for (const result of report.results) {
    lines.push(
      `### ${result.route} ${result.viewport} ${result.code}`,
      '',
      `- Title: ${result.title}`,
      `- Source: ${result.source ?? 'unknown'}`,
      `- Message: ${result.message}`,
      `- Description: ${result.description || 'none'}`,
      `- Element: \`${result.element.selector}\``,
      '',
      '```html',
      result.element.html,
      '```',
      ''
    );
  }

  return `${lines.join('\n')}\n`;
}

const viewports = parseViewports();
const routes = await discoverAuditRoutes();

if (routes.length === 0) {
  throw new Error('No HTML routes found to audit.');
}

await setupBrowserTmp(browserTmp);
await mkdir(path.dirname(outputJson), { recursive: true });
await mkdir(path.dirname(outputMarkdown), { recursive: true });

const port = await getFreePort();
const baseUrl = `http://${HOST}:${port}/`;
let devServer;
let browser;
const results = [];

try {
  devServer = startAstroDev(port);
  await waitForDevServer(baseUrl, devServer);

  browser = await openBrowser(browserTmp);

  for (const viewport of viewports) {
    const context = await browser.newContext({
      viewport,
      javaScriptEnabled: true,
    });
    await context.route('**/*', (route) => {
      if (route.request().resourceType() === 'media') {
        route.abort('blockedbyclient');
      } else {
        route.continue();
      }
    });

    try {
      for (const route of routes) {
        results.push(...(await auditRoute(context, baseUrl, route, viewport)));
      }
    } finally {
      await context.close().catch(() => {});
    }
  }

  const report = {
    generatedAt: new Date().toISOString(),
    baseUrl,
    routes,
    viewports,
    results,
  };

  await writeFile(outputJson, `${JSON.stringify(report, null, 2)}\n`);
  await writeFile(outputMarkdown, renderMarkdown(report));

  console.log(
    `Exported ${results.length} Astro audit issue${
      results.length === 1 ? '' : 's'
    } across ${routes.length} routes.`
  );
  console.log(`JSON: ${path.relative(process.cwd(), outputJson)}`);
  console.log(`Markdown: ${path.relative(process.cwd(), outputMarkdown)}`);

  if (results.length > 0) {
    for (const result of results.slice(0, 20)) {
      console.log(
        `- ${result.route} [${result.viewport}] ${result.code}: ${result.title}${
          result.source ? ` (${result.source})` : ''
        }`
      );
    }
    if (results.length > 20) {
      console.log(`...and ${results.length - 20} more. See the report files.`);
    }
  }

  if (failOnIssues && results.length > 0) {
    process.exitCode = 1;
  }
} finally {
  if (browser) await safeCloseBrowser(browser);
  if (devServer) await stopProcess(devServer);
  await cleanupBrowserTmp(browserTmp);
}
