import type {
  CompatibilityContract,
  ProtocolEvent,
  ProtocolFixture,
  ProtocolScenario,
  SerializationMetadata,
  StreamDecodeChunking,
} from "../../../fixtures/protocol/types.js";

export type { ProtocolFixture };

export const PROTOCOL_CAPABILITY = "universal-protocol";

export type ProtocolViolation = {
  scenarioId: string;
  kind: string;
  fact: string;
  details?: unknown;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function valuesEqual(left: unknown, right: unknown): boolean {
  return JSON.stringify(left) === JSON.stringify(right);
}

export function sanitizeForEvidence(value: unknown): unknown {
  if (typeof value === "number" && !Number.isFinite(value)) {
    return String(value);
  }
  if (Array.isArray(value)) {
    return value.map((entry) => sanitizeForEvidence(entry));
  }
  if (isRecord(value)) {
    return Object.fromEntries(
      Object.entries(value).map(([key, entry]) => [key, sanitizeForEvidence(entry)]),
    );
  }
  return value;
}

export function isRuleExcepted(fixture: ProtocolFixture, rule: string): boolean {
  return fixture.intentionalException?.rules.includes(rule) ?? false;
}

function parseProtocolEvent(value: unknown, index: number): ProtocolEvent {
  if (!isRecord(value) || typeof value.type !== "string") {
    throw new Error(`events[${String(index)}] must include type`);
  }
  const event: ProtocolEvent = { type: value.type };
  if (typeof value.seq === "number") {
    event.seq = value.seq;
  }
  if ("payload" in value) {
    event.payload = value.payload;
  }
  return event;
}

function parseChunking(value: unknown, index: number): StreamDecodeChunking {
  if (!isRecord(value)) {
    throw new Error(`chunkings[${String(index)}] must be an object`);
  }
  if (typeof value.chunkingId !== "string" || value.chunkingId.trim().length === 0) {
    throw new Error(`chunkings[${String(index)}].chunkingId must be a non-empty string`);
  }
  if (!Array.isArray(value.chunks) || value.chunks.some((chunk) => typeof chunk !== "string")) {
    throw new Error(`chunkings[${String(index)}].chunks must be a string array`);
  }
  const chunks = value.chunks.filter((chunk): chunk is string => typeof chunk === "string");
  if (!isRecord(value.observation)) {
    throw new Error(`chunkings[${String(index)}].observation must be an object`);
  }
  if (!Array.isArray(value.observation.events)) {
    throw new Error(`chunkings[${String(index)}].observation.events must be an array`);
  }
  if (typeof value.observation.terminalCount !== "number") {
    throw new Error(`chunkings[${String(index)}].observation.terminalCount must be a number`);
  }
  if (!Array.isArray(value.observation.parseErrors)) {
    throw new Error(`chunkings[${String(index)}].observation.parseErrors must be an array`);
  }

  return {
    chunkingId: value.chunkingId,
    chunks,
    observation: {
      events: value.observation.events.map((event, eventIndex) =>
        parseProtocolEvent(event, eventIndex),
      ),
      terminalCount: value.observation.terminalCount,
      parseErrors: value.observation.parseErrors.filter(
        (entry): entry is string => typeof entry === "string",
      ),
    },
  };
}

function parseCompatibilityContract(value: unknown): CompatibilityContract {
  if (!isRecord(value)) {
    return {};
  }
  const contract: CompatibilityContract = {};
  if (value.allowUnknownFields === true) {
    contract.allowUnknownFields = true;
  }
  if (value.rejectVersionSkew === true) {
    contract.rejectVersionSkew = true;
  }
  if (typeof value.expectedVersion === "number") {
    contract.expectedVersion = value.expectedVersion;
  }
  if (value.charset === "utf-8" || value.charset === "utf-16") {
    contract.charset = value.charset;
  }
  if (value.newline === "lf" || value.newline === "crlf") {
    contract.newline = value.newline;
  }
  if (
    value.bom === "reject" ||
    value.bom === "strip" ||
    value.bom === "preserve" ||
    value.bom === "forbid"
  ) {
    contract.bom = value.bom;
  }
  if (value.compression === "none" || value.compression === "gzip") {
    contract.compression = value.compression;
  }
  if (value.rejectPartialWrites === true) {
    contract.rejectPartialWrites = true;
  }
  return contract;
}

function parseSerializationMetadata(value: unknown): SerializationMetadata {
  if (!isRecord(value)) {
    return {};
  }
  const metadata: SerializationMetadata = {};
  if (typeof value.contentType === "string") {
    metadata.contentType = value.contentType;
  }
  if (value.hasBom === true) {
    metadata.hasBom = true;
  }
  if (value.newlineStyle === "lf" || value.newlineStyle === "crlf" || value.newlineStyle === "mixed") {
    metadata.newlineStyle = value.newlineStyle;
  }
  if (value.compressed === true) {
    metadata.compressed = true;
  }
  if (value.partialWrite === true) {
    metadata.partialWrite = true;
  }
  if (Array.isArray(value.unknownFields)) {
    metadata.unknownFields = value.unknownFields.filter(
      (entry): entry is string => typeof entry === "string",
    );
  }
  if (typeof value.inputVersion === "number") {
    metadata.inputVersion = value.inputVersion;
  }
  if (typeof value.outputVersion === "number") {
    metadata.outputVersion = value.outputVersion;
  }
  return metadata;
}

function parseScenario(value: unknown, index: number): ProtocolScenario {
  if (!isRecord(value)) {
    throw new Error(`scenarios[${String(index)}] must be an object`);
  }
  if (typeof value.scenarioId !== "string" || value.scenarioId.trim().length === 0) {
    throw new Error(`scenarios[${String(index)}].scenarioId must be a non-empty string`);
  }
  if (typeof value.kind !== "string") {
    throw new Error(`scenarios[${String(index)}].kind must be a string`);
  }

  const scenarioId = value.scenarioId;

  switch (value.kind) {
    case "stream-decode": {
      if (!Array.isArray(value.frames) || value.frames.some((frame) => typeof frame !== "string")) {
        throw new Error(`scenarios[${String(index)}].frames must be a string array`);
      }
      const frames = value.frames.filter((frame): frame is string => typeof frame === "string");
      if (typeof value.terminalType !== "string") {
        throw new Error(`scenarios[${String(index)}].terminalType must be a string`);
      }
      if (!Array.isArray(value.chunkings) || value.chunkings.length === 0) {
        throw new Error(`scenarios[${String(index)}].chunkings must be a non-empty array`);
      }
      const constraints = isRecord(value.constraints) ? value.constraints : {};
      return {
        scenarioId,
        kind: "stream-decode",
        frames,
        terminalType: value.terminalType,
        constraints: {
          preserveOrder: constraints.preserveOrder === true,
          requireSingleTerminal: constraints.requireSingleTerminal === true,
          chunkBoundaryIndependent: constraints.chunkBoundaryIndependent === true,
          requireParseable: constraints.requireParseable === true,
        },
        chunkings: value.chunkings.map((chunking, chunkingIndex) =>
          parseChunking(chunking, chunkingIndex),
        ),
      };
    }
    case "stdout-purity": {
      if (typeof value.stdout !== "string") {
        throw new Error(`scenarios[${String(index)}].stdout must be a string`);
      }
      if (!isRecord(value.observation)) {
        throw new Error(`scenarios[${String(index)}].observation must be an object`);
      }
      const observation = value.observation;
      if (!Array.isArray(observation.protocolFrames)) {
        throw new Error(`scenarios[${String(index)}].observation.protocolFrames must be an array`);
      }
      if (!Array.isArray(observation.contaminations)) {
        throw new Error(`scenarios[${String(index)}].observation.contaminations must be an array`);
      }
      return {
        scenarioId,
        kind: "stdout-purity",
        stdout: value.stdout,
        ...(typeof value.stderr === "string" ? { stderr: value.stderr } : {}),
        ...(Array.isArray(value.declaredSideChannels)
          ? {
              declaredSideChannels: value.declaredSideChannels.filter(
                (entry): entry is string => typeof entry === "string",
              ),
            }
          : {}),
        ...(typeof value.terminalType === "string" ? { terminalType: value.terminalType } : {}),
        observation: {
          protocolFrames: observation.protocolFrames.filter(
            (entry): entry is string => typeof entry === "string",
          ),
          contaminations: observation.contaminations.map((entry, contaminationIndex) => {
            if (!isRecord(entry) || typeof entry.offset !== "number" || typeof entry.text !== "string") {
              throw new Error(
                `scenarios[${String(index)}].observation.contaminations[${String(contaminationIndex)}] must include offset and text`,
              );
            }
            return { offset: entry.offset, text: entry.text };
          }),
          partialFrames:
            typeof observation.partialFrames === "number" ? observation.partialFrames : 0,
          terminalCount:
            typeof observation.terminalCount === "number" ? observation.terminalCount : 0,
          duplicateTerminalCount:
            typeof observation.duplicateTerminalCount === "number"
              ? observation.duplicateTerminalCount
              : 0,
          postTerminalEvents:
            typeof observation.postTerminalEvents === "number"
              ? observation.postTerminalEvents
              : 0,
        },
      };
    }
    case "serialization-round-trip": {
      if (!isRecord(value.observation)) {
        throw new Error(`scenarios[${String(index)}].observation must be an object`);
      }
      if (typeof value.observation.serialized !== "string") {
        throw new Error(`scenarios[${String(index)}].observation.serialized must be a string`);
      }
      return {
        scenarioId,
        kind: "serialization-round-trip",
        typedValue: value.typedValue,
        compatibilityContract: parseCompatibilityContract(value.compatibilityContract),
        observation: {
          serialized: value.observation.serialized,
          deserialized: value.observation.deserialized,
          metadata: parseSerializationMetadata(value.observation.metadata),
        },
      };
    }
    default:
      throw new Error(`unsupported scenario kind: ${value.kind}`);
  }
}

export function parseProtocolFixture(input: unknown): ProtocolFixture {
  if (!isRecord(input)) {
    throw new Error("protocol fixture must be an object");
  }
  if (typeof input.fixtureId !== "string" || input.fixtureId.trim().length === 0) {
    throw new Error("fixtureId must be a non-empty string");
  }
  if (typeof input.protocolVersion !== "string" || input.protocolVersion.trim().length === 0) {
    throw new Error("protocolVersion must be a non-empty string");
  }
  if (!Array.isArray(input.scenarios) || input.scenarios.length === 0) {
    throw new Error("scenarios must be a non-empty array");
  }

  const fixture: ProtocolFixture = {
    fixtureId: input.fixtureId,
    protocolVersion: input.protocolVersion,
    scenarios: input.scenarios.map((scenario, index) => parseScenario(scenario, index)),
  };

  if (isRecord(input.intentionalException)) {
    const rules = input.intentionalException.rules;
    const reason = input.intentionalException.reason;
    if (!Array.isArray(rules) || rules.some((rule) => typeof rule !== "string")) {
      throw new Error("intentionalException.rules must be a string array");
    }
    const parsedRules = rules.filter((rule): rule is string => typeof rule === "string");
    if (parsedRules.length !== rules.length) {
      throw new Error("intentionalException.rules must be a string array");
    }
    if (typeof reason !== "string" || reason.trim().length === 0) {
      throw new Error("intentionalException.reason must be a non-empty string");
    }
    fixture.intentionalException = { rules: parsedRules, reason };
  }

  return fixture;
}

function eventsSignature(events: ProtocolEvent[]): string {
  return JSON.stringify(events);
}

function hasMonotonicSeq(events: ProtocolEvent[]): boolean {
  let previous: number | undefined;
  for (const event of events) {
    if (typeof event.seq !== "number") {
      continue;
    }
    if (previous !== undefined && event.seq < previous) {
      return false;
    }
    previous = event.seq;
  }
  return true;
}

export function detectUni060Violations(scenario: ProtocolScenario): ProtocolViolation[] {
  if (scenario.kind !== "stream-decode") {
    return [];
  }

  const violations: ProtocolViolation[] = [];
  const { constraints, chunkings } = scenario;

  if (constraints.requireParseable) {
    for (const chunking of chunkings) {
      if (chunking.observation.parseErrors.length > 0) {
        violations.push({
          scenarioId: scenario.scenarioId,
          kind: "unparseable-frame",
          fact: "stream produced parse errors for promised parseable protocol",
          details: {
            chunkingId: chunking.chunkingId,
            parseErrors: chunking.observation.parseErrors,
          },
        });
        break;
      }
    }
  }

  if (constraints.requireSingleTerminal) {
    for (const chunking of chunkings) {
      if (chunking.observation.terminalCount !== 1) {
        violations.push({
          scenarioId: scenario.scenarioId,
          kind: "terminal-count-violation",
          fact: "stream did not emit exactly one terminal record",
          details: {
            chunkingId: chunking.chunkingId,
            terminalCount: chunking.observation.terminalCount,
          },
        });
      }
    }
  }

  if (constraints.preserveOrder) {
    for (const chunking of chunkings) {
      if (!hasMonotonicSeq(chunking.observation.events)) {
        violations.push({
          scenarioId: scenario.scenarioId,
          kind: "order-violation",
          fact: "decoded event order violated promised sequence",
          details: {
            chunkingId: chunking.chunkingId,
            events: sanitizeForEvidence(chunking.observation.events),
          },
        });
      }
    }
  }

  if (constraints.chunkBoundaryIndependent && chunkings.length > 1) {
    const reference = eventsSignature(chunkings[0]?.observation.events ?? []);
    const referenceTerminal = chunkings[0]?.observation.terminalCount;
    for (const chunking of chunkings.slice(1)) {
      if (eventsSignature(chunking.observation.events) !== reference) {
        violations.push({
          scenarioId: scenario.scenarioId,
          kind: "chunk-boundary-dependence",
          fact: "decoded events differ across chunk boundaries",
          details: {
            referenceChunkingId: chunkings[0]?.chunkingId,
            chunkingId: chunking.chunkingId,
            referenceEvents: sanitizeForEvidence(chunkings[0]?.observation.events),
            observedEvents: sanitizeForEvidence(chunking.observation.events),
          },
        });
        break;
      }
      if (chunking.observation.terminalCount !== referenceTerminal) {
        violations.push({
          scenarioId: scenario.scenarioId,
          kind: "chunk-boundary-terminal-dependence",
          fact: "terminal record count differs across chunk boundaries",
          details: {
            referenceChunkingId: chunkings[0]?.chunkingId,
            chunkingId: chunking.chunkingId,
            referenceTerminalCount: referenceTerminal,
            observedTerminalCount: chunking.observation.terminalCount,
          },
        });
        break;
      }
    }
  }

  return violations;
}

function stdoutContaminationExcepted(scenario: Extract<ProtocolScenario, { kind: "stdout-purity" }>): boolean {
  return (scenario.declaredSideChannels ?? []).includes("stdout-operator");
}

export function detectUni061Violations(scenario: ProtocolScenario): ProtocolViolation[] {
  if (scenario.kind !== "stdout-purity") {
    return [];
  }

  const violations: ProtocolViolation[] = [];
  const { observation } = scenario;

  if (observation.contaminations.length > 0 && !stdoutContaminationExcepted(scenario)) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "stdout-contamination",
      fact: "human-readable text contaminated machine-readable stdout",
      details: sanitizeForEvidence(observation.contaminations),
    });
  }

  if (observation.partialFrames > 0) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "partial-frame",
      fact: "stdout ended with partial protocol frame",
      details: { partialFrames: observation.partialFrames },
    });
  }

  if (observation.terminalCount !== 1) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "terminal-count-violation",
      fact: "stdout protocol did not emit exactly one terminal event",
      details: { terminalCount: observation.terminalCount },
    });
  }

  if (observation.duplicateTerminalCount > 0) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "duplicate-terminal",
      fact: "stdout protocol emitted duplicate terminal event",
      details: { duplicateTerminalCount: observation.duplicateTerminalCount },
    });
  }

  if (observation.postTerminalEvents > 0) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "post-terminal-event",
      fact: "stdout protocol emitted events after terminal record",
      details: { postTerminalEvents: observation.postTerminalEvents },
    });
  }

  return violations;
}

export function detectUni062Violations(scenario: ProtocolScenario): ProtocolViolation[] {
  if (scenario.kind !== "serialization-round-trip") {
    return [];
  }

  const violations: ProtocolViolation[] = [];
  const { typedValue, compatibilityContract, observation } = scenario;
  const { metadata } = observation;

  if (!valuesEqual(typedValue, observation.deserialized)) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "typed-meaning-lost",
      fact: "serialization round-trip did not preserve typed meaning",
      details: {
        typedValue: sanitizeForEvidence(typedValue),
        deserialized: sanitizeForEvidence(observation.deserialized),
      },
    });
  }

  if (
    compatibilityContract.allowUnknownFields !== true &&
    (metadata.unknownFields?.length ?? 0) > 0
  ) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "unknown-field-contract-violation",
      fact: "unknown fields were not handled per compatibility contract",
      details: { unknownFields: metadata.unknownFields },
    });
  }

  if (
    compatibilityContract.rejectVersionSkew === true &&
    compatibilityContract.expectedVersion !== undefined &&
    metadata.inputVersion !== undefined &&
    metadata.inputVersion !== compatibilityContract.expectedVersion
  ) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "version-skew",
      fact: "version skew accepted without explicit compatibility contract",
      details: {
        expectedVersion: compatibilityContract.expectedVersion,
        inputVersion: metadata.inputVersion,
        outputVersion: metadata.outputVersion,
      },
    });
  }

  if (
    (compatibilityContract.bom === "forbid" || compatibilityContract.bom === "reject") &&
    metadata.hasBom === true
  ) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "bom-contract-violation",
      fact: "byte-order mark present without allowed compatibility contract",
      details: { hasBom: metadata.hasBom },
    });
  }

  if (
    compatibilityContract.newline !== undefined &&
    metadata.newlineStyle !== undefined &&
    metadata.newlineStyle !== compatibilityContract.newline
  ) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "newline-contract-violation",
      fact: "newline style violated explicit compatibility contract",
      details: {
        expected: compatibilityContract.newline,
        observed: metadata.newlineStyle,
      },
    });
  }

  if (
    compatibilityContract.compression === "none" &&
    metadata.compressed === true
  ) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "compression-contract-violation",
      fact: "compressed payload violated explicit none compression contract",
      details: { compressed: metadata.compressed },
    });
  }

  if (compatibilityContract.rejectPartialWrites === true && metadata.partialWrite === true) {
    violations.push({
      scenarioId: scenario.scenarioId,
      kind: "partial-write",
      fact: "partial write truncated serialized payload",
      details: { serialized: observation.serialized },
    });
  }

  return violations;
}
