import {
  mkdtemp,
  mkdir,
  readFile,
  rm,
  symlink,
  writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
  assertLicenseReady,
  collectDirectoryEntries,
  createDeterministicZip,
  parseReleaseArguments,
  safeArchivePath,
} from '../../scripts/package-release.mjs';
import {
  checksumLine,
  parseChecksumLine,
  sha256,
  verifyChecksum,
  writeChecksum,
} from '../../scripts/checksum-release.mjs';
import {
  inspectPortalHeaders,
  parsePortalArguments,
} from '../../scripts/portal-assembly-smoke.mjs';

const temporaryDirectories = [];

async function temporaryDirectory() {
  const directory = await mkdtemp(
    path.join(tmpdir(), 'av-tools-release-test-')
  );
  temporaryDirectories.push(directory);
  return directory;
}

afterEach(async () => {
  await Promise.all(
    temporaryDirectories
      .splice(0)
      .map((directory) => rm(directory, { force: true, recursive: true }))
  );
});

describe('release path safety', () => {
  it.each([
    '',
    '/index.html',
    '../index.html',
    'assets/../index.html',
    'assets//main.js',
    'C:/index.html',
    'assets\\main.js',
    'assets/\0main.js',
  ])('rejects unsafe archive path %j', (unsafePath) => {
    expect(() => safeArchivePath(unsafePath)).toThrow();
  });

  it('accepts a canonical nested path', () => {
    expect(safeArchivePath('vendor/ffmpeg/core.wasm')).toBe(
      'vendor/ffmpeg/core.wasm'
    );
  });

  it('rejects symlinks while collecting a directory', async () => {
    const directory = await temporaryDirectory();
    await writeFile(path.join(directory, 'target.txt'), 'target');
    await symlink('target.txt', path.join(directory, 'alias.txt'));
    await expect(collectDirectoryEntries(directory)).rejects.toThrow(
      /symlink/i
    );
  });
});

describe('deterministic release ZIP', () => {
  const entries = [
    { name: 'z.txt', data: Buffer.from('last') },
    { name: 'a.txt', data: Buffer.from('first') },
  ];

  it('is byte-identical regardless of input order', () => {
    const first = createDeterministicZip(entries);
    const second = createDeterministicZip([...entries].reverse());
    expect(first.equals(second)).toBe(true);
    expect(sha256(first)).toMatch(/^[a-f0-9]{64}$/);
  });

  it('stores sorted UTF-8 entries with the normalized epoch and file mode', () => {
    const archive = createDeterministicZip(entries);
    expect(archive.readUInt32LE(0)).toBe(0x04034b50);
    expect(archive.readUInt16LE(10)).toBe(0);
    expect(archive.readUInt16LE(12)).toBe(33);
    expect(archive.subarray(30, 35).toString('utf8')).toBe('a.txt');

    const centralOffset = archive.indexOf(
      Buffer.from([0x50, 0x4b, 0x01, 0x02])
    );
    expect(centralOffset).toBeGreaterThan(0);
    expect(archive.readUInt16LE(centralOffset + 4)).toBe(0x0314);
    expect(archive.readUInt32LE(centralOffset + 38) >>> 16).toBe(0o100644);
  });

  it('rejects duplicate paths and local absolute paths in text', () => {
    expect(() =>
      createDeterministicZip([
        { name: 'same.txt', data: Buffer.from('one') },
        { name: 'same.txt', data: Buffer.from('two') },
      ])
    ).toThrow(/duplicate/i);
    expect(() =>
      createDeterministicZip([
        { name: 'bundle.js', data: Buffer.from('source: "/home/user/file"') },
      ])
    ).toThrow(/local absolute path/i);
  });
});

describe('licence release gate', () => {
  it('blocks a normal release without an application licence', async () => {
    const directory = await temporaryDirectory();
    await expect(assertLicenseReady(directory)).rejects.toThrow(
      /RELEASE BLOCKED/
    );
  });

  it('allows only the explicit development-smoke escape hatch', async () => {
    const directory = await temporaryDirectory();
    await expect(assertLicenseReady(directory, true)).resolves.toBeNull();
    expect(
      parseReleaseArguments(['--allow-unlicensed-development-smoke'])
    ).toEqual({
      allowUnlicensedDevelopmentSmoke: true,
      force: false,
    });
    expect(() => parseReleaseArguments(['--publish'])).toThrow(/unknown/i);
  });

  it('recognizes a complete GPL version 3 root application licence', async () => {
    const directory = await temporaryDirectory();
    const licence = path.join(directory, 'LICENSE');
    await writeFile(
      licence,
      [
        'GNU GENERAL PUBLIC LICENSE',
        'Version 3, 29 June 2007',
        'END OF TERMS AND CONDITIONS',
      ].join('\n')
    );
    await expect(assertLicenseReady(directory)).resolves.toBe(licence);
  });

  it('rejects an arbitrary file named LICENSE', async () => {
    const directory = await temporaryDirectory();
    await writeFile(path.join(directory, 'LICENSE'), 'example only');
    await expect(assertLicenseReady(directory)).rejects.toThrow(
      /complete GNU GPL version 3/i
    );
  });
});

describe('release checksum', () => {
  it('formats and parses the portable sidecar form', () => {
    const digest = 'a'.repeat(64);
    const line = checksumLine(digest, 'av-tools-0.2.1.zip');
    expect(line).toBe(`${digest}  av-tools-0.2.1.zip\n`);
    expect(parseChecksumLine(line)).toEqual({
      archiveName: 'av-tools-0.2.1.zip',
      digest,
    });
  });

  it('writes and verifies a checksum sidecar', async () => {
    const directory = await temporaryDirectory();
    const archive = path.join(directory, 'av-tools-0.2.1.zip');
    await writeFile(archive, 'deterministic bytes');
    const result = await writeChecksum(archive);
    expect(await verifyChecksum(archive, result.sidecarPath)).toBe(
      result.digest
    );
    expect(await readFile(result.sidecarPath, 'utf8')).toContain(
      '  av-tools-0.2.1.zip'
    );
  });

  it('rejects checksum mismatches', async () => {
    const directory = await temporaryDirectory();
    const archive = path.join(directory, 'av-tools-0.2.1.zip');
    const sidecar = `${archive}.sha256`;
    await writeFile(archive, 'changed');
    await mkdir(path.dirname(sidecar), { recursive: true });
    await writeFile(
      sidecar,
      checksumLine('0'.repeat(64), path.basename(archive))
    );
    await expect(verifyChecksum(archive, sidecar)).rejects.toThrow(/mismatch/i);
  });
});

describe('portal assembly smoke validation', () => {
  const isolatedHeaders = `
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    add_header Cross-Origin-Resource-Policy "same-origin" always;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; media-src 'self' blob:;" always;
    types { application/wasm wasm; }
    "~^/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/(?:st|mt)/ffmpeg-core\\.(?:js|wasm)$" "public, max-age=31536000, immutable";
  `;

  it('accepts the required isolation, CSP and MIME profile', () => {
    expect(inspectPortalHeaders(isolatedHeaders)).toEqual({
      errors: [],
      warnings: [],
    });
  });

  it('detects the current media-preview CSP gap', () => {
    const inspection = inspectPortalHeaders(
      isolatedHeaders.replace("media-src 'self' blob:;", '')
    );
    expect(inspection.errors).toContain(
      "CSP media-src must contain 'self' and blob:"
    );
  });

  it('does not mistake a plain-semver cache rule for reviewed-build coverage', () => {
    const inspection = inspectPortalHeaders(
      isolatedHeaders.replace('0.12.10-reviewed-stack5m.1', '0.12.10')
    );
    expect(inspection.warnings).toContainEqual(
      expect.stringMatching(/reviewed FFmpeg build/u)
    );
  });

  it('parses only explicit smoke overrides and paths', () => {
    const parsed = parsePortalArguments(
      [
        '--artifact',
        'release/example.zip',
        '--allow-unlicensed-development-smoke',
        '--allow-known-header-gap',
        '--keep-temp',
      ],
      '/work/av-tools'
    );
    expect(parsed).toEqual({
      allowKnownHeaderGap: true,
      allowUnlicensedDevelopmentSmoke: true,
      artifact: '/work/av-tools/release/example.zip',
      keepTemp: true,
      portalRoot: '/work/references/toolbox-portal',
    });
    expect(() => parsePortalArguments(['--publish'], '/work/av-tools')).toThrow(
      /unknown/i
    );
  });
});
