#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixturesDir = "protocol/examples/evidence-chain/fixtures";

const idPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
const sha256Pattern = /^[a-f0-9]{64}$/;

const rootRequired = [
  "chainId",
  "schemaVersion",
  "scenario",
  "projectId",
  "operationId",
  "traceId",
  "environment",
  "hardwareOperation",
  "auditEvents",
  "traceEvents",
  "evidenceRecords"
];

const hardwareOperationRequired = [
  "operationId",
  "projectId",
  "gatewaySessionId",
  "resourceId",
  "capabilityId",
  "requestedBy",
  "status",
  "environment",
  "requestedAt",
  "updatedAt"
];

const auditEventRequired = [
  "auditId",
  "traceId",
  "actorType",
  "actorId",
  "action",
  "targetType",
  "targetId",
  "serviceId",
  "environment",
  "occurredAt",
  "projectId",
  "operationId",
  "outcome"
];

const traceEventRequired = [
  "traceEventId",
  "traceId",
  "projectId",
  "serviceId",
  "level",
  "message",
  "environment",
  "occurredAt",
  "operationId"
];

const evidenceRecordRequired = [
  "evidenceId",
  "projectId",
  "operationId",
  "kind",
  "uri",
  "sha256",
  "serviceId",
  "environment",
  "createdAt"
];

const operationStatuses = new Set([
  "requested",
  "accepted",
  "queued",
  "running",
  "succeeded",
  "failed",
  "rejected",
  "canceled",
  "timed_out"
]);

const actorTypes = new Set(["user", "service", "agent", "worker", "system"]);
const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]);
const traceLevels = new Set(["debug", "info", "warn", "error"]);
const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]);
const scenarios = new Set(["hardware-trusted-closed-loop", "agent-automation-closed-loop"]);

function assertObject(value, label) {
  assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`);
}

function assertRequired(value, fields, label) {
  for (const field of fields) {
    assert.ok(Object.hasOwn(value, field), `${label} missing ${field}`);
  }
}

function assertId(value, label) {
  assert.equal(typeof value, "string", `${label} must be a string`);
  assert.match(value, idPattern, `${label} must match HWLAB id format`);
}

function assertTimestamp(value, label) {
  assert.equal(typeof value, "string", `${label} must be a string`);
  assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be a parseable timestamp`);
}

function assertServiceId(value, label) {
  assert.ok(SERVICE_IDS.includes(value), `${label} must be a frozen service id`);
}

function assertUnique(values, label) {
  assert.equal(new Set(values).size, values.length, `${label} must be unique`);
}

function assertSame(actual, expected, label) {
  assert.equal(actual, expected, `${label} must match ${expected}`);
}

async function parseFixtures() {
  const entries = await readdir(path.join(repoRoot, fixturesDir), { withFileTypes: true });
  const files = entries
    .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
    .map((entry) => path.join(fixturesDir, entry.name))
    .sort();

  const fixtures = [];
  for (const relativePath of files) {
    const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
    fixtures.push({ relativePath, doc: JSON.parse(raw) });
  }
  return fixtures;
}

async function assertArtifact(record, label) {
  assert.match(record.sha256, sha256Pattern, `${label}.sha256 must be lowercase sha256`);
  assert.equal(typeof record.uri, "string", `${label}.uri must be a string`);

  const artifactPath = path.resolve(repoRoot, record.uri);
  assert.ok(
    artifactPath.startsWith(`${repoRoot}${path.sep}`),
    `${label}.uri must point inside the repository`
  );

  const content = await readFile(artifactPath);
  const digest = createHash("sha256").update(content).digest("hex");
  assert.equal(digest, record.sha256, `${label}.sha256 must match ${record.uri}`);

  if (Object.hasOwn(record, "sizeBytes")) {
    assert.equal(record.sizeBytes, content.byteLength, `${label}.sizeBytes must match ${record.uri}`);
  }
}

function validateHardwareOperation(chain, operation) {
  assertObject(operation, `${chain.chainId}.hardwareOperation`);
  assertRequired(operation, hardwareOperationRequired, `${chain.chainId}.hardwareOperation`);

  for (const field of [
    "operationId",
    "projectId",
    "gatewaySessionId",
    "resourceId",
    "capabilityId",
    "requestedBy"
  ]) {
    assertId(operation[field], `${chain.chainId}.hardwareOperation.${field}`);
  }

  assertSame(operation.operationId, chain.operationId, `${chain.chainId}.hardwareOperation.operationId`);
  assertSame(operation.projectId, chain.projectId, `${chain.chainId}.hardwareOperation.projectId`);
  assertSame(operation.environment, chain.environment, `${chain.chainId}.hardwareOperation.environment`);
  assert.ok(operationStatuses.has(operation.status), `${chain.chainId}.hardwareOperation.status is invalid`);
  assertTimestamp(operation.requestedAt, `${chain.chainId}.hardwareOperation.requestedAt`);
  assertTimestamp(operation.updatedAt, `${chain.chainId}.hardwareOperation.updatedAt`);

  for (const field of ["startedAt", "completedAt"]) {
    if (Object.hasOwn(operation, field)) {
      assertTimestamp(operation[field], `${chain.chainId}.hardwareOperation.${field}`);
    }
  }

  if (chain.agentSessionId) {
    assertSame(operation.agentSessionId, chain.agentSessionId, `${chain.chainId}.hardwareOperation.agentSessionId`);
  }
  if (chain.workerSessionId) {
    assertSame(operation.workerSessionId, chain.workerSessionId, `${chain.chainId}.hardwareOperation.workerSessionId`);
  }
}

function validateAuditEvents(chain, auditEvents) {
  assert.ok(Array.isArray(auditEvents), `${chain.chainId}.auditEvents must be an array`);
  assert.ok(auditEvents.length >= 2, `${chain.chainId}.auditEvents must include operation and evidence audit events`);
  assertUnique(
    auditEvents.map((event) => event.auditId),
    `${chain.chainId}.auditId values`
  );

  for (const [index, event] of auditEvents.entries()) {
    const label = `${chain.chainId}.auditEvents[${index}]`;
    assertObject(event, label);
    assertRequired(event, auditEventRequired, label);

    for (const field of ["auditId", "traceId", "actorId", "targetId", "projectId", "operationId"]) {
      assertId(event[field], `${label}.${field}`);
    }

    assertSame(event.traceId, chain.traceId, `${label}.traceId`);
    assertSame(event.projectId, chain.projectId, `${label}.projectId`);
    assertSame(event.operationId, chain.operationId, `${label}.operationId`);
    assertSame(event.environment, chain.environment, `${label}.environment`);
    assert.ok(actorTypes.has(event.actorType), `${label}.actorType is invalid`);
    assert.match(event.action, actionPattern, `${label}.action must be dotted`);
    assert.equal(typeof event.targetType, "string", `${label}.targetType must be a string`);
    assertServiceId(event.serviceId, `${label}.serviceId`);
    assert.ok(auditOutcomes.has(event.outcome), `${label}.outcome is invalid`);
    assertTimestamp(event.occurredAt, `${label}.occurredAt`);

    if (Object.hasOwn(event, "gatewaySessionId")) {
      assertSame(event.gatewaySessionId, chain.gatewaySessionId, `${label}.gatewaySessionId`);
    }
    if (chain.workerSessionId && Object.hasOwn(event, "workerSessionId")) {
      assertSame(event.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`);
    }
  }

  assert.ok(
    auditEvents.some((event) => event.targetType === "hardware_operation" && event.targetId === chain.operationId),
    `${chain.chainId} must audit the hardware operation`
  );
}

function validateTraceEvents(chain, traceEvents, auditIds) {
  assert.ok(Array.isArray(traceEvents), `${chain.chainId}.traceEvents must be an array`);
  assert.ok(traceEvents.length >= 1, `${chain.chainId}.traceEvents must not be empty`);
  assertUnique(
    traceEvents.map((event) => event.traceEventId),
    `${chain.chainId}.traceEventId values`
  );

  for (const [index, event] of traceEvents.entries()) {
    const label = `${chain.chainId}.traceEvents[${index}]`;
    assertObject(event, label);
    assertRequired(event, traceEventRequired, label);

    for (const field of ["traceEventId", "traceId", "projectId", "operationId"]) {
      assertId(event[field], `${label}.${field}`);
    }

    assertSame(event.traceId, chain.traceId, `${label}.traceId`);
    assertSame(event.projectId, chain.projectId, `${label}.projectId`);
    assertSame(event.operationId, chain.operationId, `${label}.operationId`);
    assertSame(event.environment, chain.environment, `${label}.environment`);
    assertServiceId(event.serviceId, `${label}.serviceId`);
    assert.ok(traceLevels.has(event.level), `${label}.level is invalid`);
    assert.equal(typeof event.message, "string", `${label}.message must be a string`);
    assert.ok(event.message.length > 0, `${label}.message must not be empty`);
    assertTimestamp(event.occurredAt, `${label}.occurredAt`);

    if (chain.agentSessionId) {
      assertSame(event.agentSessionId, chain.agentSessionId, `${label}.agentSessionId`);
    }
    if (chain.workerSessionId) {
      assertSame(event.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`);
    }
    if (event.metadata?.auditId) {
      assert.ok(auditIds.has(event.metadata.auditId), `${label}.metadata.auditId must reference same-chain audit`);
    }
  }
}

async function validateEvidenceRecords(chain, evidenceRecords, auditEvents) {
  assert.ok(Array.isArray(evidenceRecords), `${chain.chainId}.evidenceRecords must be an array`);
  assert.ok(evidenceRecords.length >= 1, `${chain.chainId}.evidenceRecords must not be empty`);
  assertUnique(
    evidenceRecords.map((record) => record.evidenceId),
    `${chain.chainId}.evidenceId values`
  );

  const auditsById = new Map(auditEvents.map((event) => [event.auditId, event]));

  for (const [index, record] of evidenceRecords.entries()) {
    const label = `${chain.chainId}.evidenceRecords[${index}]`;
    assertObject(record, label);
    assertRequired(record, evidenceRecordRequired, label);

    for (const field of ["evidenceId", "projectId", "operationId"]) {
      assertId(record[field], `${label}.${field}`);
    }

    assertSame(record.projectId, chain.projectId, `${label}.projectId`);
    assertSame(record.operationId, chain.operationId, `${label}.operationId`);
    assertSame(record.environment, chain.environment, `${label}.environment`);
    assert.ok(evidenceKinds.has(record.kind), `${label}.kind is invalid`);
    assertServiceId(record.serviceId, `${label}.serviceId`);
    assertTimestamp(record.createdAt, `${label}.createdAt`);
    assertObject(record.metadata, `${label}.metadata`);
    assertSame(record.metadata.traceId, chain.traceId, `${label}.metadata.traceId`);
    assertId(record.metadata.producedByAuditId, `${label}.metadata.producedByAuditId`);

    const producerAudit = auditsById.get(record.metadata.producedByAuditId);
    assert.ok(producerAudit, `${label}.metadata.producedByAuditId must reference same-chain audit`);
    assert.equal(producerAudit.targetType, "evidence_record", `${label} producer audit must target evidence_record`);
    assert.equal(producerAudit.targetId, record.evidenceId, `${label} producer audit must target this evidenceId`);

    if (chain.agentSessionId) {
      assertSame(record.agentSessionId, chain.agentSessionId, `${label}.agentSessionId`);
      assertSame(record.metadata.agentSessionId, chain.agentSessionId, `${label}.metadata.agentSessionId`);
    }
    if (chain.workerSessionId) {
      assertSame(record.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`);
      assertSame(record.metadata.workerSessionId, chain.workerSessionId, `${label}.metadata.workerSessionId`);
    }

    await assertArtifact(record, label);
  }
}

async function validateFixture({ relativePath, doc }) {
  assertObject(doc, relativePath);
  assertRequired(doc, rootRequired, relativePath);

  for (const field of ["chainId", "projectId", "operationId", "traceId"]) {
    assertId(doc[field], `${relativePath}.${field}`);
  }

  assert.equal(doc.schemaVersion, 1, `${relativePath}.schemaVersion must be 1`);
  assert.ok(scenarios.has(doc.scenario), `${relativePath}.scenario is not recognized`);
  assert.equal(doc.environment, ENVIRONMENT_DEV, `${relativePath}.environment must be dev`);

  for (const field of ["gatewaySessionId", "resourceId", "capabilityId", "agentSessionId", "workerSessionId"]) {
    if (Object.hasOwn(doc, field)) {
      assertId(doc[field], `${relativePath}.${field}`);
    }
  }

  if (doc.scenario === "agent-automation-closed-loop") {
    assertId(doc.agentSessionId, `${relativePath}.agentSessionId`);
    assertId(doc.workerSessionId, `${relativePath}.workerSessionId`);
  }

  validateHardwareOperation(doc, doc.hardwareOperation);
  validateAuditEvents(doc, doc.auditEvents);

  const auditIds = new Set(doc.auditEvents.map((event) => event.auditId));
  validateTraceEvents(doc, doc.traceEvents, auditIds);
  await validateEvidenceRecords(doc, doc.evidenceRecords, doc.auditEvents);
}

const fixtures = await parseFixtures();

assert.ok(fixtures.length >= 2, "expected at least two MVP evidence chain fixtures");

const scenarioSet = new Set(fixtures.map(({ doc }) => doc.scenario));
for (const scenario of scenarios) {
  assert.ok(scenarioSet.has(scenario), `missing ${scenario} fixture`);
}

for (const fixture of fixtures) {
  await validateFixture(fixture);
}

const evidenceCount = fixtures.reduce((count, { doc }) => count + doc.evidenceRecords.length, 0);
console.log(`validated ${fixtures.length} evidence chain fixtures and ${evidenceCount} evidence records`);
