import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';

const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const RELEASE_VARIANTS = Object.freeze(['singleplayer', 'multiplayer', 'replay']);
const RETAIL_POLICY = 'operator-authorized-retail-v1';

function sha256(bytes) {
  return createHash('sha256').update(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)).digest('hex');
}

function parseManifest(bytes, label) {
  try {
    return JSON.parse(Buffer.from(bytes).toString('utf8'));
  } catch (error) {
    throw new Error(`${label} is not valid JSON: ${error.message}`);
  }
}

function artifactHash(value, label) {
  const normalized = String(value || '').toLowerCase();
  assert.match(normalized, SHA256_PATTERN, `${label} must be a SHA-256 digest`);
  return normalized;
}

export function makeReleaseIdentity(engineManifestBytes, contentManifestBytes, retailManifestBytes) {
  const engineManifest = parseManifest(engineManifestBytes, 'engine manifest');
  const contentManifest = parseManifest(contentManifestBytes, 'content manifest');
  const retailManifest = parseManifest(retailManifestBytes, 'retail manifest');

  assert.equal(engineManifest.schema, 2, 'release evidence requires a schema-2 engine manifest');
  assert.equal(contentManifest.schema, 2, 'release evidence requires a schema-2 content manifest');
  assert.equal(retailManifest.schema, 1, 'release evidence requires a schema-1 retail manifest');
  assert.equal(retailManifest.policy, RETAIL_POLICY, 'retail manifest policy is invalid');
  assert.match(engineManifest.build || '', /\S/, 'engine manifest build is required');
  assert.match(contentManifest.build || '', /\S/, 'content manifest build is required');

  const variants = {};
  for (const name of RELEASE_VARIANTS) {
    const variant = engineManifest.variants?.[name];
    assert.ok(variant && typeof variant === 'object', `engine manifest variant ${name} is required`);
    variants[name] = {
      scriptSha256: artifactHash(variant.sha256?.script, `${name} script`),
      wasmSha256: artifactHash(variant.sha256?.wasm, `${name} wasm`),
      gameLib: {
        filename: String(variant.gameLib?.filename || ''),
        sha256: artifactHash(variant.gameLib?.sha256, `${name} GameLib`)
      }
    };
    assert.match(variants[name].gameLib.filename, /^game-(sp|mp)_wasm\.wasm$/,
      `${name} GameLib filename is invalid`);
  }

  const files = (contentManifest.files || []).map((entry) => ({
    name: String(entry?.name || ''),
    sha256: artifactHash(entry?.sha256, `content file ${entry?.name || '<unnamed>'}`)
  }));
  assert.ok(files.length > 0, 'content manifest must contain release files');

  const retailFingerprint = artifactHash(retailManifest.fingerprint, 'retail fingerprint');
  assert.equal(retailManifest.revision, `q4retail-${retailFingerprint.slice(0, 20)}`,
    'retail manifest revision does not match its fingerprint');
  const retailNames = new Set();
  let retailTotalBytes = 0;
  const retailFiles = (retailManifest.files || []).map((entry) => {
    const name = String(entry?.name || '');
    const size = Number(entry?.size);
    const sha256Digest = artifactHash(entry?.sha256, `retail file ${name || '<unnamed>'}`);
    const url = String(entry?.url || '');
    assert.match(name, /^[a-z0-9._-]+\.pk4$/, `retail file name is invalid: ${name || '<unnamed>'}`);
    assert.ok(!retailNames.has(name), `retail file name is duplicated: ${name}`);
    assert.ok(Number.isSafeInteger(size) && size > 0, `retail file size is invalid: ${name}`);
    assert.equal(url, `/retail/q4base/${sha256Digest}-${encodeURIComponent(name)}`,
      `retail file URL is invalid: ${name}`);
    retailNames.add(name);
    retailTotalBytes += size;
    return { name, size, sha256: sha256Digest, url };
  });
  assert.ok(retailFiles.length > 0, 'retail manifest must contain release files');
  assert.ok(Number.isSafeInteger(retailTotalBytes), 'retail manifest total size is invalid');
  assert.equal(retailManifest.totalBytes, retailTotalBytes,
    'retail manifest totalBytes does not match its files');

  const identity = {
    engine: {
      manifestSha256: sha256(engineManifestBytes),
      schema: engineManifest.schema,
      build: engineManifest.build,
      variants
    },
    content: {
      manifestSha256: sha256(contentManifestBytes),
      schema: contentManifest.schema,
      build: contentManifest.build,
      runtimeSourceSha256: artifactHash(contentManifest.runtimeSourceSha256, 'content runtime source'),
      files
    },
    retail: {
      manifestSha256: sha256(retailManifestBytes),
      schema: retailManifest.schema,
      policy: retailManifest.policy,
      revision: retailManifest.revision,
      fingerprint: retailFingerprint,
      totalBytes: retailTotalBytes,
      files: retailFiles
    }
  };
  return {
    ...identity,
    identitySha256: sha256(Buffer.from(JSON.stringify(identity)))
  };
}

export async function readReleaseIdentity({
  engineManifest = new URL('../engine/engine-manifest.json', import.meta.url),
  contentManifest = new URL('../content/assets/baseoq4/manifest.json', import.meta.url),
  retailManifest = new URL('../runtime/retail-manifest.json', import.meta.url)
} = {}) {
  const [engineBytes, contentBytes, retailBytes] = await Promise.all([
    fs.readFile(engineManifest),
    fs.readFile(contentManifest),
    fs.readFile(retailManifest)
  ]);
  return makeReleaseIdentity(engineBytes, contentBytes, retailBytes);
}

export function sameReleaseIdentity(left, right) {
  if (!SHA256_PATTERN.test(left?.identitySha256 || '') || left.identitySha256 !== right?.identitySha256) return false;
  return JSON.stringify(left) === JSON.stringify(right);
}

export function retainCurrentPhaseEvidence(previous, currentIdentity, validPhases, expectedTarget = null) {
  const oldEvidence = previous?.evidence && typeof previous.evidence === 'object' ? previous.evidence : {};
  const oldChecks = previous?.phaseChecks && typeof previous.phaseChecks === 'object' ? previous.phaseChecks : {};
  const evidence = {};
  const phaseChecks = {};
  const invalidatedPhases = [];

  for (const phase of validPhases) {
    if (!Object.hasOwn(oldEvidence, phase)) continue;
    const check = oldChecks[phase];
    if (sameReleaseIdentity(check?.releaseIdentity, currentIdentity)) {
      if (expectedTarget && (check.baseUrl !== expectedTarget.baseUrl || check.gamePort !== expectedTarget.gamePort)) {
        invalidatedPhases.push({
          phase,
          reason: 'e2e-target-changed',
          previousIdentitySha256: check.releaseIdentity.identitySha256
        });
        continue;
      }
      evidence[phase] = oldEvidence[phase];
      phaseChecks[phase] = check;
      continue;
    }
    invalidatedPhases.push({
      phase,
      reason: check?.releaseIdentity ? 'release-identity-changed' : 'missing-release-identity',
      previousIdentitySha256: check?.releaseIdentity?.identitySha256 || null
    });
  }

  return { evidence, phaseChecks, invalidatedPhases };
}

export function extractDelimitedLogBlock(logs, begin, end) {
  assert.ok(Array.isArray(logs), 'engine logs must be an array');
  assert.match(begin, /^Q4_E2E_[A-Z0-9_]+$/, 'invalid begin marker');
  assert.match(end, /^Q4_E2E_[A-Z0-9_]+$/, 'invalid end marker');
  const normalized = logs.map((line) => String(line).trim());
  const beginIndex = normalized.lastIndexOf(begin);
  assert.notEqual(beginIndex, -1, `missing fresh log marker ${begin}`);
  const relativeEnd = normalized.slice(beginIndex + 1).indexOf(end);
  assert.notEqual(relativeEnd, -1, `missing fresh log marker ${end}`);
  return normalized.slice(beginIndex + 1, beginIndex + 1 + relativeEnd);
}

export function parseServerInfoBlock(lines) {
  const clients = [];
  let botMinPlayers = null;
  let maxPlayers = null;
  let terminal = null;

  for (const line of lines) {
    let match = /^client\s+(\d+):\s*([^,]+),\s*ping\s*=\s*(-?\d+),\s*rate\s*=\s*(-?\d+)\s*$/i.exec(line);
    if (match) {
      clients.push({ slot: Number(match[1]), name: match[2].trim(), ping: Number(match[3]), rate: Number(match[4]) });
      continue;
    }
    match = /^bot_minplayers\s*=\s*(\d+)\s*$/i.exec(line);
    if (match) botMinPlayers = Number(match[1]);
    match = /^si_maxPlayers\s*=\s*(\d+)\s*$/i.exec(line);
    if (match) maxPlayers = Number(match[1]);
    if (/^-?\d+: server\s+\S+\s+- protocol\s+\d+\.\d+\s+-\s+.+$/i.test(line)) terminal = line;
  }

  clients.sort((left, right) => left.slot - right.slot);
  return { clients, botMinPlayers, maxPlayers, terminal };
}

export function firstSnapshotEvidence(logs) {
  for (const rawLine of logs) {
    const line = String(rawLine).trim();
    const match = /^received first snapshot, gameInitId = (-?\d+), gameFrame (-?\d+) gameTime (-?\d+)$/.exec(line);
    if (!match) continue;
    return {
      line,
      gameInitId: Number(match[1]),
      gameFrame: Number(match[2]),
      gameTime: Number(match[3]),
      clientState: 'CS_INGAME',
      serverStateRequiredToSendSnapshot: 'SCS_INGAME'
    };
  }
  return null;
}
