import { expect, test } from '@playwright/test';
import { qwikVite } from '@qwik.dev/core/optimizer';
import { ssgAdapter } from '@qwik.dev/router/adapters/ssg/vite';
import { qwikRouter } from '@qwik.dev/router/vite';
import compress from 'brotli/compress.js';
import { readFile, rm, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build, createBuilder, type InlineConfig, type PluginOption } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';

// Brotli size budgets for production bundles. These are ceilings, not exact matches: any size
// at or below the budget is fine. Bump the budget intentionally when a real feature justifies
// the growth.
const PRELOADER_BROTLI_BUDGET = 1800; // We currently group the vite preload helper with the preloader, adding ~500bytes brotli.
const CORE_BROTLI_BUDGET = 35400;
const QWIKLOADER_BROTLI_BUDGET = 2100;

const __dirname = fileURLToPath(new URL('.', import.meta.url));
const repoRoot = resolve(__dirname, '../../../../');
const appDir = resolve(repoRoot, 'e2e/qwik-e2e/apps/qwikrouter-ssg-snapshot');
const distDir = resolve(appDir, 'dist');
const serverDir = resolve(appDir, 'server');
const expectedHtmlPath = resolve(appDir, 'expected.ssg.html');
const expectedStatePath = resolve(appDir, 'expected.state.txt');
const expectedConfigPath = resolve(appDir, 'expected.config.txt');

// Generated route trie, captured pre-bundle so the snapshot shows structure, not hashed chunks.
let capturedRouterConfig: string | null = null;

test.describe('router ssg snapshot', () => {
  // One shared production build via `beforeAll`. `serial` keeps Playwright's `fullyParallel` config
  // from distributing these tests across workers, which would re-run the build against the same
  // `serverDir`/`distDir` and race.
  test.describe.configure({ mode: 'serial' });

  test.skip(({ browserName }) => browserName !== 'chromium', 'Runs once in Chromium e2e.');

  test.beforeAll(async () => {
    await buildFixtureApp();
  });

  test('build output matches the checked-in normalized html and state dump', async () => {
    const { _dumpState } = await import(resolve(serverDir, 'entry.ssr.js'));

    const html = await readFile(resolve(distDir, 'index.html'), 'utf-8');
    const state = extractState(html);
    expect(state).not.toBeNull();

    const manifestHash = extractManifestHash(html);
    const normalizedHtml = normalizeHtml(html, manifestHash);
    const normalizedState = normalizeStateDump(
      _dumpState(JSON.parse(state!) as unknown[], false, '', null),
      manifestHash
    );

    // Regression guard: loaderPaths must map the root loader to "/", or off-route loaders fetch the
    // wrong URL and 404. The hash is wildcarded (optimizer-derived); the route path "/" is stable.
    expect(normalizedState).toMatch(
      /"loaderPaths"\s+Object \[\s+\{string\} "[^"]+"\s+\{string\} "\/"/
    );

    let expectedHtml = (await readFile(expectedHtmlPath, 'utf-8').catch(() => '')).replace(
      /\r\n/g,
      '\n'
    );
    let expectedState = (await readFile(expectedStatePath, 'utf-8').catch(() => '')).replace(
      /\r\n/g,
      '\n'
    );

    warnIfSizeChanged('readable state dump', expectedState, normalizedState);
    warnIfSizeChanged('HTML', expectedHtml, normalizedHtml);

    if (process.env.UPDATE_SSG_SNAPSHOT === '1') {
      await writeFile(expectedHtmlPath, normalizedHtml, 'utf-8');
      await writeFile(expectedStatePath, normalizedState, 'utf-8');
      expectedHtml = normalizedHtml;
      expectedState = normalizedState;
    }

    // These can fail on dev builds, remember to run `pnpm build.core` before updating the snapshot.
    expect(normalizedState).toEqual(expectedState);
    expect(normalizedHtml).toEqual(expectedHtml);
  });

  test('generated route config (trie) matches the checked-in snapshot', async () => {
    expect(capturedRouterConfig, 'router config was captured during the client build').toBeTruthy();
    // Readable, pre-bundle trie: `_R` keeps the `__LOADERS:` placeholder, not the real loader hashes
    // `generateBundle` injects later (kept so the snapshot tracks structure, not churning hashes).
    const normalizedConfig = normalizeRouterConfig(capturedRouterConfig!);

    let expectedConfig = (await readFile(expectedConfigPath, 'utf-8').catch(() => '')).replace(
      /\r\n/g,
      '\n'
    );

    warnIfSizeChanged('route config', expectedConfig, normalizedConfig);

    if (process.env.UPDATE_SSG_SNAPSHOT === '1') {
      await writeFile(expectedConfigPath, normalizedConfig, 'utf-8');
      expectedConfig = normalizedConfig;
    }

    expect(normalizedConfig).toEqual(expectedConfig);
  });

  test('a 404.tsx boundary prerenders <dir>/404.html inside its layout', async () => {
    // sub/ has a layout + 404 (no index): the boundary is force-prerendered to sub/404.html,
    // wrapping the 404 page in sub/layout.tsx — proves the SSG miss-render + layout inheritance.
    const html = await readFile(resolve(distDir, 'sub', '404.html'), 'utf-8');
    expect(html, 'sub layout should wrap the 404 page').toContain('data-ssg-404-layout');
    expect(html, '404 page content should render').toContain('Sub Not Found');
  });

  test('preloader chunk brotli size stays within budget', async () => {
    const manifest = JSON.parse(await readFile(resolve(distDir, 'q-manifest.json'), 'utf-8'));
    const preloaderFile = manifest.preloader as string | undefined;
    expect(preloaderFile, 'q-manifest.json should expose `preloader`').toBeTruthy();

    await checkBrotliBudget('preloader', preloaderFile!, PRELOADER_BROTLI_BUDGET);
  });

  test('core chunk brotli size stays within budget', async () => {
    const manifest = JSON.parse(await readFile(resolve(distDir, 'q-manifest.json'), 'utf-8'));
    const coreFile = manifest.core as string | undefined;
    expect(coreFile, 'q-manifest.json should expose `core`').toBeTruthy();

    await checkBrotliBudget('core', coreFile!, CORE_BROTLI_BUDGET);
  });

  test('qwikloader chunk brotli size stays within budget', async () => {
    const manifest = JSON.parse(await readFile(resolve(distDir, 'q-manifest.json'), 'utf-8'));
    const qwikLoaderFile = manifest.qwikLoader as string | undefined;
    expect(qwikLoaderFile, 'q-manifest.json should expose `qwikLoader`').toBeTruthy();

    await checkBrotliBudget('qwikloader', qwikLoaderFile!, QWIKLOADER_BROTLI_BUDGET);
  });
});

/**
 * Brotli size guardrail. The bundle's brotli size must stay at or below `budget`. If it grows past
 * the budget the test fails with the actual size — bump the budget at the top of this file when the
 * growth is expected.
 */
async function checkBrotliBudget(label: string, path: string, budget: number) {
  const fullPath = resolve(distDir, 'build', path);
  const content = await readFile(fullPath, 'utf-8');
  expect(content.length).toBeGreaterThan(0);
  const brotli = compress(Buffer.from(content), { mode: 1, quality: 11 }).length;
  // Logged on every run so size trends are visible in test output without having to fail first.
  // `console.warn` (not `console.log`) because the repo's `no-console` rule allows warn/error only.
  console.warn(
    `[ssg-snapshot] ${label.padEnd(10)} brotli=${brotli} raw=${content.length} file=${fullPath}`
  );
  const constantName = `${label.toUpperCase()}_BROTLI_BUDGET`;
  const overage = brotli - budget;
  expect(
    brotli,
    `${label} bundle is ${brotli} bytes brotli — ${overage} bytes over the ${budget}-byte budget. Make sure you are using the production build. If this growth is intentional, bump \`${constantName}\` to a higher value. File: ${fullPath}`
  ).toBeLessThanOrEqual(budget);
}

// enforce:'pre' so we capture qwik-router's load() output before the bundler rewrites its imports.
function captureRouterConfig(): PluginOption {
  return {
    name: 'capture-router-config',
    enforce: 'pre',
    transform(code, id) {
      if (id.endsWith('@qwik-router-config')) {
        capturedRouterConfig = code;
      }
      return null;
    },
  };
}

async function buildFixtureApp() {
  await rm(distDir, { recursive: true, force: true });
  await rm(serverDir, { recursive: true, force: true });
  capturedRouterConfig = null;

  const plugins: PluginOption[] = [qwikRouter(), tsconfigPaths({ root: '.' })];
  // Fresh instance so the server build's loadersByFile starts empty, mirroring apps that run
  // build.client/build.server as separate processes (exercises the manifest recovery path).
  const serverPlugins: PluginOption[] = [qwikRouter(), tsconfigPaths({ root: '.' })];

  const getConfig = (extra?: InlineConfig): InlineConfig => ({
    root: appDir,
    mode: 'production',
    configFile: false,
    clearScreen: false,
    logLevel: 'error',
    build: {
      minify: 'terser',
    },
    ...extra,
  });

  await build(
    getConfig({
      plugins: [
        ...plugins,
        captureRouterConfig(),
        qwikVite({
          client: {
            outDir: distDir,
          },
        }),
      ],
    })
  );

  const builder = await createBuilder(
    getConfig({
      build: {
        minify: true,
        ssr: true,
        outDir: serverDir,
      },
      plugins: [
        ...serverPlugins,
        // No manifestInput: the server build reads the client manifest from disk, like real apps.
        qwikVite(),
        ssgAdapter({
          origin: 'https://snapshot.qwik.dev',
          include: ['/*'],
        }),
      ],
    })
  );
  await builder.buildApp();
}

function extractManifestHash(html: string): string | null {
  const match = html.match(/q:manifest-hash="([^"]*)"/);
  return match ? match[1] : null;
}

function normalizeHtml(html: string, manifestHash: string | null) {
  let result = html;
  if (manifestHash) {
    result = result.replaceAll(manifestHash, 'MANIFEST_HASH');
  }
  result = result
    .replace(/\r\n/g, '\n')
    .replace(/ q:version="[^"]*"/g, '')
    .replace(/ q:instance="[^"]*"/g, ' q:instance="[instance]"')
    .replace(/\/assets\/[A-Za-z0-9_-]+-bundle-graph\.json/g, '/assets/xxxxxxxx-bundle-graph.json')
    .replace(/q-[A-Za-z0-9_-]+\.(js|css)/g, 'q-xxxxxxxx.$1')
    .replace(/qFuncs_[A-Za-z0-9_-]+/g, 'qFuncs_xxxxxx')
    .replace(/,0,"[A-Za-z0-9_-]+"/g, ',0,"[instance]"')
    .replace(/<script type="qwik\/state"[^>]*>[\s\S]*?<\/script>/, '[state omitted]\n')
    .replace(/<script type="qwik\/vnode"[^>]*>[\s\S]*?<\/script>/, '[vnode map omitted]\n');
  return result;
}

function extractState(html: string) {
  const match = html.match(/<script type="qwik\/state"[^>]*>([\s\S]*?)<\/script>/);
  return match ? match[1] : null;
}

function normalizeStateDump(stateDump: string, manifestHash: string | null) {
  let result = stateDump
    .replace(/\r\n/g, '\n')
    .replace(/manifestHash"\n\s+\{string\} "[^"]+"/g, 'manifestHash"\n    {string} "MANIFEST_HASH"')
    .replace(/qFuncs_[A-Za-z0-9_-]+/g, 'qFuncs_xxxxxx')
    .replace(/q-[A-Za-z0-9_-]+\.(js|css)/g, 'q-xxxxxxxx.$1')
    .replaceAll(/RootRef .*/g, 'RootRef [omitted]')
    .replaceAll(/QRL ".*"/g, 'QRL "[omitted]"')
    .replace(/^\(\d+ chars\)$/m, '');
  if (manifestHash) {
    result = result.replaceAll(manifestHash, 'MANIFEST_HASH');
  }
  return result;
}

function normalizeRouterConfig(code: string) {
  // Strip machine-specific absolute paths so the snapshot is checkout-independent. Match the
  // forward-slash form: config paths are posix, but resolve() yields backslashes on Windows.
  const posix = (p: string) => p.replaceAll('\\', '/');
  return code
    .replace(/\r\n/g, '\n')
    .replaceAll(posix(appDir), '<app>')
    .replaceAll(posix(repoRoot), '<root>');
}

function warnIfSizeChanged(label: string, expected: string, actual: string) {
  const expectedLen = expected.length;
  const actualLen = actual.length;
  if (expectedLen === 0) {
    return;
  }
  const pct = Math.abs(actualLen - expectedLen) / expectedLen;
  if (pct > 0.01) {
    console.error(
      `\n\n[ssg-snapshot] ${label} size changed by ${(pct * 100).toFixed(1)}%: ` +
        `${expectedLen} → ${actualLen} chars (did you remember to pnpm build.core ?)`
    );
  }
}
