/**
 * Light-witness — a passive observer of one or more KT logs.
 *
 * Responsibilities (V1):
 *   1. Pin a log_public_key.
 *   2. Periodically poll the server's `/v1/kt/sth` endpoint.
 *   3. Verify each new STH's signature.
 *   4. Maintain a chain of observed STHs and verify consistency proofs
 *      between successive observations.
 *   5. Expose a "compare" API: given an STH another party has seen
 *      (e.g. delivered with a bundle-fetch), return whether *we* have
 *      seen the same `tree_size → root_hash → index_root` triple. A
 *      mismatch is a *split-view alarm*.
 *
 * V1 is library-only; deployments embed it in long-running processes
 * (CLI tools, security-research auditors, server-to-server). V2 will
 * add an HTTP `GET /witness/sth` endpoint for peer-to-peer gossip.
 */

import type { CryptoProvider } from '@shade/core';
import {
  type SignedTreeHead,
  type STHWire,
  computeLogId,
  sthFromWire,
  verifySthSignature,
} from './sth.js';
import { verifyConsistencyProof } from './log.js';
import { fromBase64 } from './util.js';
import { constantTimeEqual } from './util.js';
import {
  KTLogIdMismatchError,
  KTSplitViewError,
  KTStaleSTHError,
  KTVerificationError,
} from './errors.js';

export interface WitnessFetcher {
  /** GET latest STH. */
  fetchLatestSTH(): Promise<STHWire>;
  /** GET consistency proof from `fromTreeSize` to `toTreeSize`. */
  fetchConsistencyProof(fromTreeSize: number, toTreeSize: number): Promise<{ proof: string[] }>;
}

export interface LightWitnessOptions {
  crypto: CryptoProvider;
  /** Pinned log signing public key (Ed25519, 32 bytes). */
  logPublicKey: Uint8Array;
  /** Source of STHs and consistency proofs. */
  fetcher: WitnessFetcher;
  /** Reject STH older than this many ms. Default 24h. */
  maxStaleMs?: number;
  /** Allowed clock-skew for STH future timestamps. Default 60 s. */
  futureSkewMs?: number;
  /** Time source. Defaults to `Date.now()`. */
  now?: () => number;
  /** Cap on stored STHs (LRU on tree_size). Default 1024. */
  maxStored?: number;
}

const DEFAULT_MAX_STALE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_FUTURE_SKEW_MS = 60_000;
const DEFAULT_MAX_STORED = 1024;

/** A piece of evidence that a particular STH was observed. */
export interface WitnessObservation {
  sth: SignedTreeHead;
  observedAtMs: number;
}

export class LightWitness {
  private readonly crypto: CryptoProvider;
  private readonly logPublicKey: Uint8Array;
  private readonly fetcher: WitnessFetcher;
  private readonly logId: Uint8Array;
  private readonly maxStaleMs: number;
  private readonly futureSkewMs: number;
  private readonly now: () => number;
  private readonly maxStored: number;

  /** STHs we've observed, indexed by tree_size. */
  private observed = new Map<number, SignedTreeHead>();
  private observedOrder: number[] = []; // insertion order for LRU eviction

  constructor(opts: LightWitnessOptions) {
    this.crypto = opts.crypto;
    this.logPublicKey = opts.logPublicKey;
    this.fetcher = opts.fetcher;
    this.logId = computeLogId(opts.logPublicKey);
    this.maxStaleMs = opts.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
    this.futureSkewMs = opts.futureSkewMs ?? DEFAULT_FUTURE_SKEW_MS;
    this.now = opts.now ?? (() => Date.now());
    this.maxStored = opts.maxStored ?? DEFAULT_MAX_STORED;
  }

  /** Pinned log_id for callers that want to verify message routing. */
  getLogId(): Uint8Array {
    return new Uint8Array(this.logId);
  }

  /**
   * Poll the server for the latest STH. Verifies signature, freshness, and
   * consistency with the most recent observation we hold. Adds the STH to
   * the local set on success.
   */
  async pollOnce(): Promise<SignedTreeHead> {
    const wire = await this.fetcher.fetchLatestSTH();
    const sth = sthFromWire(wire, fromBase64);
    await this.observe(sth);
    return sth;
  }

  /**
   * Ingest an STH supplied externally (e.g. embedded in a bundle-fetch
   * response). Re-uses all the same verification & comparison logic so
   * proofs returned by the bundle-fetch path also feed into split-view
   * detection.
   */
  async observe(sth: SignedTreeHead): Promise<void> {
    if (!constantTimeEqual(sth.logId, this.logId)) {
      throw new KTLogIdMismatchError(`STH log_id does not match pinned key`);
    }

    const ok = await verifySthSignature(this.crypto, sth, this.logPublicKey);
    if (!ok) throw new KTVerificationError('STH signature did not verify');

    const now = this.now();
    if (sth.timestampMs > now + this.futureSkewMs) {
      throw new KTStaleSTHError(`STH timestamp in the future`);
    }
    if (sth.timestampMs + this.maxStaleMs < now) {
      throw new KTStaleSTHError(`STH older than maxStale`);
    }

    // Split-view check — same tree_size, different root or index_root → fork.
    const prior = this.observed.get(sth.treeSize);
    if (prior) {
      if (
        !constantTimeEqual(prior.rootHash, sth.rootHash) ||
        !constantTimeEqual(prior.indexRoot, sth.indexRoot)
      ) {
        throw new KTSplitViewError(
          `Split view: two STHs at tree_size=${sth.treeSize} disagree`,
        );
      }
      // Same STH content; nothing to insert (might just be a refreshed timestamp).
      // Keep the freshest one for staleness checks.
      if (sth.timestampMs > prior.timestampMs) {
        this.observed.set(sth.treeSize, sth);
      }
      return;
    }

    // Consistency check against our most recent prior observation.
    const latest = this.latestObserved();
    if (latest && latest.treeSize !== sth.treeSize) {
      const [oldSth, newSth] = latest.treeSize < sth.treeSize ? [latest, sth] : [sth, latest];
      if (oldSth.treeSize > 0 && oldSth.treeSize < newSth.treeSize) {
        const { proof } = await this.fetcher.fetchConsistencyProof(
          oldSth.treeSize,
          newSth.treeSize,
        );
        const proofBytes = proof.map(fromBase64);
        const consistent = verifyConsistencyProof(
          oldSth.treeSize,
          newSth.treeSize,
          oldSth.rootHash,
          newSth.rootHash,
          proofBytes,
        );
        if (!consistent) {
          throw new KTVerificationError(
            `Consistency proof failed: ${oldSth.treeSize} → ${newSth.treeSize}`,
          );
        }
      }
    }

    this.insertObservation(sth);
  }

  /**
   * Compare an external STH (e.g. one another client claims to have seen)
   * against our stored set. Returns:
   *   - 'agree' if we've observed the same tree_size with the same roots,
   *   - 'unknown' if we have no STH at that tree_size (caller may want to
   *     poll once more before deciding),
   *   - 'split-view' if our roots differ.
   */
  compare(sth: SignedTreeHead): 'agree' | 'unknown' | 'split-view' {
    const ours = this.observed.get(sth.treeSize);
    if (!ours) return 'unknown';
    const sameRoot = constantTimeEqual(ours.rootHash, sth.rootHash);
    const sameIndex = constantTimeEqual(ours.indexRoot, sth.indexRoot);
    if (sameRoot && sameIndex) return 'agree';
    return 'split-view';
  }

  /** Latest observed STH (highest tree_size). */
  latestObserved(): SignedTreeHead | null {
    let best: SignedTreeHead | null = null;
    for (const sth of this.observed.values()) {
      if (!best || sth.treeSize > best.treeSize) best = sth;
    }
    return best;
  }

  /** Snapshot all observed STHs (defensive copy). */
  observations(): WitnessObservation[] {
    return Array.from(this.observed.values()).map((sth) => ({
      sth: {
        treeSize: sth.treeSize,
        timestampMs: sth.timestampMs,
        rootHash: new Uint8Array(sth.rootHash),
        indexRoot: new Uint8Array(sth.indexRoot),
        logId: new Uint8Array(sth.logId),
        signature: new Uint8Array(sth.signature),
      },
      observedAtMs: this.now(),
    }));
  }

  private insertObservation(sth: SignedTreeHead): void {
    if (!this.observed.has(sth.treeSize)) {
      this.observedOrder.push(sth.treeSize);
    }
    this.observed.set(sth.treeSize, sth);

    while (this.observedOrder.length > this.maxStored) {
      const evict = this.observedOrder.shift();
      if (evict !== undefined && evict !== sth.treeSize) {
        // Always keep the latest STH even under aggressive eviction.
        if (this.latestObserved()?.treeSize !== evict) {
          this.observed.delete(evict);
        }
      }
    }
  }
}
