import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';

import { runPreflight, sourceFingerprint } from './preflight.mjs';

const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);

const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];

async function fixture(name) {
  const root = path.join(fixtureRoot, name);
  await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true });
  await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n');
  return root;
}

async function installRequiredBins(root) {
  const binDir = path.join(root, 'node_modules', '.bin');
  await mkdir(binDir, { recursive: true });
  await Promise.all(
    requiredBins.map(async (name) => {
      const target = path.join(binDir, name);
      await writeFile(target, '');
      await chmod(target, 0o755);
    }),
  );
}

async function certifyGeneratedState(root, links = []) {
  const nextDir = path.join(root, 'apps', 'web', '.next');
  await mkdir(nextDir, { recursive: true });
  const manifest = `${JSON.stringify({ version: 1, links })}\n`;
  const manifestHash = createHash('sha256').update(manifest).digest('hex');
  await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
  await writeFile(
    path.join(nextDir, '.mosaic-source-hash'),
    `${JSON.stringify({
      version: 1,
      sourceFingerprint: await sourceFingerprint(root),
      symlinkManifestHash: manifestHash,
    })}\n`,
  );
}

test.after(async () => {
  await rm(fixtureRoot, { recursive: true, force: true });
});

test('missing dependencies have a dedicated exit code and install remediation', async () => {
  const root = await fixture('missing-deps');
  const result = await runPreflight({ root });

  assert.equal(result.code, 42);
  assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
  assert.match(result.message, /run pnpm install/i);
});

test('a partial dependency install keeps the dedicated missing-deps result', async () => {
  const root = await fixture('partial-deps');
  await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
  await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });

  const result = await runPreflight({ root });
  assert.equal(result.code, 42);
  assert.match(result.message, /turbo/);
});

test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
  const root = await fixture('dangling-deps');
  await installRequiredBins(root);
  const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
  await rm(turbo);
  await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);

  const result = await runPreflight({ root });
  assert.equal(result.code, 42);
  assert.match(result.message, /turbo/);
});

test('installed dependencies pass when generated state is absent', async () => {
  const root = await fixture('clean');
  await installRequiredBins(root);

  assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
});

test('foreign-owned generated Next state is identified separately from source errors', async () => {
  const root = await fixture('foreign-next');
  await installRequiredBins(root);
  const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
  await mkdir(path.dirname(generated), { recursive: true });
  await writeFile(generated, 'generated output');

  const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 });
  assert.equal(result.code, 43);
  assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
  assert.match(result.message, /foreign-owned/);
});

test('a generated marker mismatch is identified separately from source errors', async () => {
  const root = await fixture('stale-next');
  await installRequiredBins(root);
  const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
  await mkdir(path.dirname(generated), { recursive: true });
  await writeFile(generated, 'stale generated output');
  await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source');

  const result = await runPreflight({ root });
  assert.equal(result.code, 43);
  assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
  assert.match(result.message, /apps\/web\/\.next/);
  assert.match(result.message, /pnpm clean:generated/);
});

test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
  await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
    const root = await fixture('symbolic-next-root');
    await installRequiredBins(root);
    await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
    await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));

    const result = await runPreflight({ root });
    assert.equal(result.code, 43);
    assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
    assert.match(result.message, /symbolic link/);
  });

  await t.test('apps/web/.next is rejected when it is not a directory', async () => {
    const root = await fixture('non-directory-next-root');
    await installRequiredBins(root);
    await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n');

    const result = await runPreflight({ root });
    assert.equal(result.code, 43);
    assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
    assert.match(result.message, /real directory/);
  });

  await t.test('an added descendant symlink is rejected', async () => {
    const root = await fixture('symbolic-next-added');
    await installRequiredBins(root);
    await certifyGeneratedState(root);
    await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));

    const result = await runPreflight({ root });
    assert.equal(result.code, 43);
    assert.match(result.message, /symbolic-link manifest/);
  });

  await t.test('a removed certified descendant symlink is rejected', async () => {
    const root = await fixture('symbolic-next-removed');
    await installRequiredBins(root);
    const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
    await mkdir(path.dirname(link), { recursive: true });
    await symlink('../dependency-one', link);
    await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
    await rm(link);

    const result = await runPreflight({ root });
    assert.equal(result.code, 43);
    assert.match(result.message, /symbolic-link manifest/);
  });

  await t.test('a retargeted certified descendant symlink is rejected', async () => {
    const root = await fixture('symbolic-next-retargeted');
    await installRequiredBins(root);
    const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
    await mkdir(path.dirname(link), { recursive: true });
    await symlink('../dependency-one', link);
    await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
    await rm(link);
    await symlink('../dependency-two', link);

    const result = await runPreflight({ root });
    assert.equal(result.code, 43);
    assert.match(result.message, /symbolic-link manifest/);
  });

  await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
    const root = await fixture('symbolic-next-tampered-manifest');
    await installRequiredBins(root);
    await certifyGeneratedState(root);
    const nextDir = path.join(root, 'apps', 'web', '.next');
    await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
    await writeFile(
      path.join(nextDir, '.mosaic-symlink-manifest'),
      `${JSON.stringify({
        version: 1,
        links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
      })}\n`,
    );

    const result = await runPreflight({ root });
    assert.equal(result.code, 43);
    assert.match(result.message, /symbolic-link manifest/);
  });

  await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
    const root = await fixture('symbolic-next-certified');
    await installRequiredBins(root);
    const link = path.join(
      root,
      'apps',
      'web',
      '.next',
      'standalone',
      'node_modules',
      'dependency',
    );
    await mkdir(path.dirname(link), { recursive: true });
    await symlink('../.pnpm/dependency', link);
    await certifyGeneratedState(root, [
      { path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
    ]);

    assert.deepEqual(await runPreflight({ root }), {
      code: 0,
      message: 'checkout preflight passed',
    });
  });
});

test('the source fingerprint includes inherited TypeScript configuration', async () => {
  const root = await fixture('inherited-typescript-config');
  const config = path.join(root, 'tsconfig.base.json');
  await writeFile(config, '{"compilerOptions":{"strict":true}}\n');
  const first = await sourceFingerprint(root);
  await writeFile(config, '{"compilerOptions":{"strict":false}}\n');
  const second = await sourceFingerprint(root);

  assert.notEqual(first, second);
});

test('the source fingerprint rejects symbolic-link build inputs', async () => {
  const root = await fixture('symbolic-source');
  await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n');
  await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts'));

  await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/);
});

test('the source fingerprint includes expanded public web build environment', async () => {
  const root = await fixture('public-build-environment');
  const envFile = path.join(root, 'apps', 'web', '.env.production');
  await writeFile(
    envFile,
    'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
  );
  const first = await sourceFingerprint(root);
  await writeFile(
    envFile,
    'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
  );
  const second = await sourceFingerprint(root);

  assert.notEqual(first, second);
});

test('a matching generation marker accepts incremental output with mixed mtimes', async () => {
  const root = await fixture('incremental-next');
  await installRequiredBins(root);
  const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
  await mkdir(path.dirname(generated), { recursive: true });
  await writeFile(generated, 'unchanged generated output');
  await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
  const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
  await writeFile(fresh, 'fresh generated output');
  await certifyGeneratedState(root);

  assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
});
