import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
  acceptSnapshot,
  assertMutable,
  computeFreshness,
  DEFAULT_FRESHNESS_POLICY,
  invalidationReasonLabels,
  type FreshPayload,
  type FreshSnapshot,
  type FreshnessPolicy,
  type FreshnessState,
  type InvalidationReason,
  StaleMutationError,
} from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';

/**
 * Freshness-aware collection fetch hook (RI-5-001).
 *
 * One hook owns one gateway collection end to end: fetch, schema validation,
 * snapshot acceptance with provenance, session-scoped last-known caching,
 * aging, and the mutation guard. Pages consume `freshness` and never infer
 * health from emptiness.
 */

/** Why the latest validation did not produce a current snapshot. */
export type FreshnessFailure =
  | { readonly kind: 'fetch'; readonly message: string }
  | { readonly kind: 'invalidated'; readonly reason: InvalidationReason };

export interface UseFreshCollectionOptions<T> {
  /** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */
  readonly source: string;
  /** Performs the unvalidated fetch. The hook owns abort and verification. */
  readonly fetcher: (signal: AbortSignal) => Promise<unknown>;
  /**
   * Runtime schema validator. Returning `null` invalidates the payload
   * (`schema-mismatch`) instead of letting malformed JSON flow into render.
   */
  readonly validate: (value: unknown) => FreshPayload<T> | null;
  /** Overrides of the default freshness policy. */
  readonly policy?: Partial<FreshnessPolicy>;
  /**
   * Session cache key for last-known snapshots. `null`/omitted disables
   * restore. Restored snapshots are unverified: they render only as
   * labeled `stale` data until a fetch re-verifies them.
   */
  readonly cacheKey?: string | null;
  /** Injectable clock for deterministic age transitions in tests. */
  readonly clock?: () => number;
  /** Aging tick interval override (default derived from `staleAfterMs`). */
  readonly tickMs?: number;
  /** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */
  readonly enabled?: boolean;
}

export interface FreshCollection<T> {
  /** Last verified (or restored-unverified) snapshot, or `null`. */
  readonly snapshot: FreshSnapshot<T> | null;
  /** Snapshot data or `null` — never a fabricated empty collection. */
  readonly data: T | null;
  readonly freshness: FreshnessState;
  /** True while a validation request is in flight. */
  readonly validating: boolean;
  /** Outcome of the latest failed validation, `null` when healthy. */
  readonly failure: FreshnessFailure | null;
  /** False unless freshness is `current`; drives disabled UI affordances. */
  readonly canMutate: boolean;
  /** Re-run the fetch and re-verify. Always allowed (it is a read). */
  readonly revalidate: () => Promise<void>;
  /**
   * Run a state-changing operation against verified-current data only.
   * Rejects with `StaleMutationError` on any other state — the guard fires
   * even if a disabled button was bypassed (defense in depth).
   */
  readonly mutate: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
}

const defaultClock = (): number => Date.now();

function resolveTickMs(policy: FreshnessPolicy, override?: number): number {
  if (override !== undefined && override > 0) return override;
  return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4)));
}

function isAuthFailure(caught: unknown): boolean {
  return (
    typeof caught === 'object' &&
    caught !== null &&
    'statusCode' in caught &&
    ((caught as { statusCode?: unknown }).statusCode === 401 ||
      (caught as { statusCode?: unknown }).statusCode === 403)
  );
}

function fetchFailureMessage(caught: unknown): string {
  if (caught instanceof Error && caught.message.trim().length > 0) return caught.message;
  return 'The request failed.';
}

/** Human-readable summary of a failure for unavailable/stale notices. */
export function describeFailure(failure: FreshnessFailure | null): string | null {
  if (failure === null) return null;
  if (failure.kind === 'fetch') return failure.message;
  return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`;
}

export function useFreshCollection<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
  const optionsRef = useRef(options);
  optionsRef.current = options;

  const policy = useMemo<FreshnessPolicy>(
    () => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }),
    [options.policy],
  );
  const policyRef = useRef(policy);
  policyRef.current = policy;

  const clockRef = useRef(options.clock ?? defaultClock);
  clockRef.current = options.clock ?? defaultClock;

  const [snapshot, setSnapshot] = useState<FreshSnapshot<T> | null>(null);
  const [failure, setFailure] = useState<FreshnessFailure | null>(null);
  const [unverified, setUnverified] = useState(false);
  const [validating, setValidating] = useState(options.enabled !== false);
  const [now, setNow] = useState(() => (options.clock ?? defaultClock)());

  const snapshotRef = useRef(snapshot);
  snapshotRef.current = snapshot;
  const failureRef = useRef(failure);
  failureRef.current = failure;
  const unverifiedRef = useRef(unverified);
  unverifiedRef.current = unverified;

  const runRef = useRef(0);
  const abortRef = useRef<AbortController | null>(null);

  const revalidate = useCallback(async (): Promise<void> => {
    const current = optionsRef.current;
    if (current.enabled === false) {
      setValidating(false);
      return;
    }

    const runId = ++runRef.current;
    abortRef.current?.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    setValidating(true);

    let value: unknown;
    try {
      value = await current.fetcher(controller.signal);
    } catch (caught) {
      if (runRef.current !== runId || controller.signal.aborted) return;
      if (isAuthFailure(caught)) {
        // An unauthenticated viewer must not keep (or be served) the
        // previous user's last-known data.
        setSnapshot(null);
        setUnverified(false);
        if (current.cacheKey) clearSnapshotCache(current.cacheKey);
      }
      setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) });
      setValidating(false);
      return;
    }

    if (runRef.current !== runId) return;

    const result = acceptSnapshot({
      value,
      validate: current.validate,
      previous: snapshotRef.current,
      policy: policyRef.current,
      source: current.source,
      now: clockRef.current(),
    });

    if (result.outcome === 'accepted') {
      setSnapshot(result.snapshot);
      setUnverified(false);
      setFailure(null);
      if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot);
    } else {
      if (result.reason === 'cross-workspace') {
        // Data verified for a different workspace must not linger as
        // last-known situational awareness either.
        setSnapshot(null);
        setUnverified(false);
      }
      if (current.cacheKey) clearSnapshotCache(current.cacheKey);
      setFailure({ kind: 'invalidated', reason: result.reason });
    }
    setValidating(false);
  }, []);

  // Restore the last-known snapshot (unverified) and run the first fetch.
  useEffect(() => {
    if (optionsRef.current.enabled === false) {
      setValidating(false);
      return;
    }

    const cacheKey = optionsRef.current.cacheKey;
    if (cacheKey) {
      const restored = readSnapshotCache<T>({
        key: cacheKey,
        workspace: policyRef.current.workspace,
        policy: policyRef.current,
        validate: optionsRef.current.validate,
      });
      if (restored.outcome === 'hit') {
        setSnapshot(restored.snapshot);
        setUnverified(true);
      } else if (restored.outcome === 'invalidated') {
        // A corrupted/foreign/regressed entry is dropped immediately; it must
        // never surface as data. The fetch decides the visible state.
        clearSnapshotCache(cacheKey);
      }
    }

    void revalidate();

    return () => {
      abortRef.current?.abort();
    };
    // Mount-once by design: `revalidate` is stable and reads live options
    // through refs, so it never needs to re-run when options change.
    // Route-param pages remount this hook via an identity `key` instead.
  }, [revalidate]);

  // Aging tick: recomputes freshness as the snapshot ages past the policy.
  useEffect(() => {
    const interval = setInterval(
      () => {
        setNow(clockRef.current());
      },
      resolveTickMs(policyRef.current, optionsRef.current.tickMs),
    );
    return () => clearInterval(interval);
  }, []);

  const freshness = useMemo<FreshnessState>(() => {
    if (snapshot === null) return validating ? 'unknown' : 'unavailable';
    return computeFreshness({
      snapshot,
      policy,
      now,
      degraded: failure !== null || unverified,
    });
    // `now` from state covers age; refs inside computeFreshness are pure.
  }, [snapshot, validating, failure, unverified, now, policy]);

  const canMutate = freshness === 'current';

  const mutate = useCallback(async <R>(operation: (data: T) => Promise<R>): Promise<R> => {
    const currentSnapshot = snapshotRef.current;
    // No verified snapshot at all: with nothing verified there is nothing
    // current to mutate, regardless of the recorded failure.
    if (currentSnapshot === null) throw new StaleMutationError('unavailable');
    const state = computeFreshness({
      snapshot: currentSnapshot,
      policy: policyRef.current,
      now: clockRef.current(),
      degraded: failureRef.current !== null || unverifiedRef.current,
    });
    assertMutable(state);
    return operation(currentSnapshot.data);
  }, []);

  return {
    snapshot,
    data: snapshot === null ? null : snapshot.data,
    freshness,
    validating,
    failure,
    canMutate,
    revalidate,
    mutate,
  };
}
