/**
 * Typed freshness model for gateway-fetched collections (RI-5-001).
 *
 * A failed or stale fetch must never be indistinguishable from an empty
 * healthy collection. Every fetched surface carries an explicit freshness
 * state, a verified snapshot identity (source, workspace, version, age), and
 * a mutation guard that refuses state-changing operations unless the data is
 * verified current.
 */

/** Freshness states for fetched data. Never inferred from emptiness. */
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';

/**
 * Reasons a snapshot is invalidated. An invalidated snapshot is treated as
 * unavailable and is never rendered as current.
 */
export type InvalidationReason =
  | 'cache-corruption'
  | 'cross-workspace'
  | 'schema-mismatch'
  | 'version-regression';

/** Human-readable labels for invalidation reasons (UI + error messages). */
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
  'cache-corruption': 'cached snapshot failed integrity checks',
  'cross-workspace': 'data belongs to a different workspace',
  'schema-mismatch': 'response did not match the expected schema',
  'version-regression': 'snapshot version regressed below the accepted version',
};

/** A verified snapshot of fetched data with full provenance. */
export interface FreshSnapshot<T> {
  readonly data: T;
  /** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
  readonly source: string;
  /** Workspace scope the data belongs to. */
  readonly workspace: string;
  /** Monotonic snapshot sequence number for this surface. */
  readonly version: number;
  /** Schema version of the validator that accepted this snapshot. */
  readonly schemaVersion: number;
  /** Epoch ms at which the data was verified. */
  readonly fetchedAt: number;
  /** Integrity digest of `data`, used to detect cache corruption. */
  readonly digest: string;
}

/** Provenance label rendered next to last-known data. */
export interface FreshnessLabel {
  readonly source: string;
  readonly version: number;
  readonly fetchedAt: number;
}

/** Policy governing freshness for a surface. */
export interface FreshnessPolicy {
  /** Active workspace scope. Snapshots from other scopes are invalidated. */
  readonly workspace: string;
  /** Schema version of the current validator. */
  readonly schemaVersion: number;
  /** Age after which a verified snapshot degrades from current to stale. */
  readonly staleAfterMs: number;
}

export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
  workspace: 'default',
  schemaVersion: 1,
  staleAfterMs: 60_000,
};

/** Payload returned by a successful schema validation. */
export interface FreshPayload<T> {
  readonly data: T;
  /**
   * Workspace identity extracted from the payload itself when the collection
   * carries one (e.g. a uniform `userId` on projects). `null` when the
   * collection has no intrinsic workspace identity.
   */
  readonly workspace: string | null;
}

/** Error thrown when a mutation is attempted on non-current data. */
export class StaleMutationError extends Error {
  readonly freshness: FreshnessState;

  constructor(freshness: FreshnessState) {
    super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
    this.name = 'StaleMutationError';
    this.freshness = freshness;
  }
}

/** Stable JSON digest used for snapshot integrity checks. */
export function computeDigest(value: unknown): string {
  // FNV-1a 32-bit over the stable JSON serialization. This is an integrity
  // check against corruption, not a cryptographic guarantee.
  let hash = 0x811c9dc5;
  for (const byte of stableStringify(value)) {
    hash ^= byte.charCodeAt(0);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, '0');
}

function stableStringify(value: unknown): string {
  return serialize(value);
}

function serialize(value: unknown): string {
  if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
  if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
  const entries = Object.entries(value as Record<string, unknown>)
    .filter(([, item]) => item !== undefined)
    .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
    .map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
  return `{${entries.join(',')}}`;
}

export type AcceptSnapshotResult<T> =
  | { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
  | { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };

export interface AcceptSnapshotOptions<T> {
  /** Raw fetched value (untrusted JSON). */
  readonly value: unknown;
  /** Schema validator; returns `null` when the value does not match. */
  readonly validate: (value: unknown) => FreshPayload<T> | null;
  /** Previously accepted snapshot for this surface, if any. */
  readonly previous: FreshSnapshot<T> | null;
  readonly policy: FreshnessPolicy;
  readonly source: string;
  /**
   * Version carried by the incoming payload when the transport exposes one.
   * Must not regress below the accepted snapshot's version.
   */
  readonly incomingVersion?: number;
  readonly now: number;
}

/**
 * Validate and accept a fetched value as a snapshot, or invalidate it.
 *
 * Invalidation rules (each treated as unavailable, never rendered current):
 * - schema mismatch: the payload fails validation
 * - cross-workspace: the payload's workspace differs from the verified one
 * - version regression: payload/schema version is below the accepted one
 */
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
  const payload = options.validate(options.value);
  if (payload === null) {
    return { outcome: 'invalidated', reason: 'schema-mismatch' };
  }

  // Workspace identity: the payload's own scope wins; a collection with no
  // intrinsic identity (e.g. an empty list after every project was deleted)
  // keeps the previously verified scope rather than resetting to the policy
  // default, so a legitimately empty response is not mistaken for a scope
  // change.
  const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
  if (options.previous !== null && options.previous.workspace !== workspace) {
    return { outcome: 'invalidated', reason: 'cross-workspace' };
  }
  if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
    return { outcome: 'invalidated', reason: 'version-regression' };
  }
  if (
    options.incomingVersion !== undefined &&
    options.previous !== null &&
    options.incomingVersion < options.previous.version
  ) {
    return { outcome: 'invalidated', reason: 'version-regression' };
  }

  const snapshot: FreshSnapshot<T> = {
    data: payload.data,
    source: options.source,
    workspace,
    version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
    schemaVersion: options.policy.schemaVersion,
    fetchedAt: options.now,
    digest: computeDigest(payload.data),
  };
  return { outcome: 'accepted', snapshot };
}

export interface ComputeFreshnessOptions {
  readonly snapshot: FreshSnapshot<unknown> | null;
  readonly policy: FreshnessPolicy;
  readonly now: number;
  /**
   * True when the snapshot cannot be trusted as current regardless of age:
   * the latest revalidation failed, or the snapshot was restored from cache
   * and has not been verified by a fetch in this session.
   */
  readonly degraded?: boolean;
}

/**
 * Compute the freshness state of a snapshot. A missing snapshot is
 * `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
 * `stale` (situational awareness only).
 */
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
  const { snapshot, policy, now, degraded = false } = options;
  if (snapshot === null) return 'unavailable';
  if (degraded) return 'stale';
  if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
  return 'current';
}

/** Only verified-current data may back a state-changing action. */
export function canMutate(state: FreshnessState): boolean {
  return state === 'current';
}

/** Defense in depth: reject the mutation call itself on non-current data. */
export function assertMutable(state: FreshnessState): void {
  if (!canMutate(state)) {
    throw new StaleMutationError(state);
  }
}

/**
 * Combine freshness across a multi-collection surface (primary + secondaries).
 * The primary collection gates the surface: unknown while it loads,
 * unavailable when it fails. Missing secondaries degrade the surface to
 * `partial`; aged collections degrade it to `stale`.
 */
export function combineFreshness(
  primary: FreshnessState,
  secondaries: readonly FreshnessState[],
): FreshnessState {
  if (primary === 'unavailable') return 'unavailable';
  if (primary === 'unknown') return 'unknown';
  if (secondaries.includes('unavailable')) return 'partial';
  if (secondaries.includes('unknown')) return 'unknown';
  if (secondaries.includes('stale') || primary === 'stale') return 'stale';
  if (secondaries.includes('partial')) return 'partial';
  return 'current';
}

/** Render-safe age label for snapshot provenance. */
export function formatAge(fetchedAt: number, now: number): string {
  const ageMs = Math.max(0, now - fetchedAt);
  if (ageMs < 10_000) return 'just now';
  const minutes = Math.floor(ageMs / 60_000);
  if (minutes < 1) return 'under a minute ago';
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  const days = Math.floor(hours / 24);
  return `${days}d ago`;
}

/** Derived verdict placeholder for non-current inputs — never a green value. */
export const UNKNOWN_VERDICT = '?';

export function verdictValue(verified: boolean, value: string): string {
  return verified ? value : UNKNOWN_VERDICT;
}
