// The pure net-state machine. A single reducer replaces the old
// two-layer connection.svelte.ts / offline.svelte.ts split: connectivity and the client-version gate
// collapse into one detected `state`, with an explicit hysteresis buffer so a brief network blip does
// not thrash the chrome. It is a pure function of (previous snapshot, event, config) — no timers, no
// storage, no reactivity — so every transition and the whole hysteresis are unit-tested in the node
// env and the machine is reusable verbatim by a future iOS shell. The reactive store, the probe timer
// and the event wiring live in O2 (netstate.svelte.ts); the effects returned here tell that store what
// to do (raise a toast, kick a probe, show the version notice, set the soft nudge).

/**
 * NetState is the single detected connectivity/version state.
 * - `online` — the gateway is reachable and the client version is accepted; full features. It may
 *   additionally carry an orthogonal dismissible "update available" nudge (the soft tier) without
 *   leaving this state.
 * - `connecting` — a call or probe is currently failing but the anti-flap window has not elapsed;
 *   the chrome stays online ("Connecting…"). Transient — never a kill switch.
 * - `offlineNoNetwork` — sustained unreachable (hysteresis exceeded): offline mode (blue chrome,
 *   local-only active lobby, transport kill switch). Self-heals when a probe or call gets through.
 * - `offlineVersionLocked` — the gateway is reachable but the version is below the hard minimum:
 *   offline mode plus the "Update / Play offline" notice. Sticky — it exits only via an app update
 *   (a fresh version on the next boot).
 */
export type NetState = 'online' | 'connecting' | 'offlineNoNetwork' | 'offlineVersionLocked';

/**
 * NetEvent is every signal that can drive the machine.
 * - `boot` — a fresh app start (the only exit from the sticky version lock).
 * - `callOk` / `callFailed` — a foreground call succeeded / failed at the transport.
 * - `probeOk` / `probeFailed` — the reachability probe succeeded / failed.
 * - `osOnline` / `osOffline` — an OS connectivity hint (navigator / @capacitor/network); a hint only
 *   triggers a probe, it never decides the state by itself.
 * - `versionRejected` — the hard gate refused the client as too old (update_required).
 * - `versionRecommended` — the soft gate signalled an available (non-blocking) update.
 */
export type NetEvent =
  | 'boot'
  | 'callOk'
  | 'callFailed'
  | 'probeOk'
  | 'probeFailed'
  | 'osOnline'
  | 'osOffline'
  | 'versionRejected'
  | 'versionRecommended';

/** NetConfig tunes the anti-flap hysteresis. */
export interface NetConfig {
  /**
   * k is the consecutive-failure threshold that trips `connecting` → `offlineNoNetwork`. It must be
   * at least 2 so a single blip (one failure then a success) rides out inside `connecting`.
   */
  k: number;
  /**
   * debounceMs is the wall-clock anti-flap window (the design's `OFFLINE_DEBOUNCE_MS`): a failure
   * whose timestamp is at least this far past the moment `connecting` was entered also trips
   * `offlineNoNetwork`, even below k. This bounds how long a slow, spaced-out failure sequence can
   * hold the chrome in "Connecting…".
   */
  debounceMs: number;
}

/**
 * NetSnapshot is the machine's full memory. Beyond the visible `state` it carries the hysteresis
 * bookkeeping (`fails`, `connectingSince`) so the reducer stays pure while still deciding both the
 * count-based and the time-based anti-flap branches; both are zero outside `connecting`.
 */
export interface NetSnapshot {
  /** state is the current detected state. */
  state: NetState;
  /** fails counts consecutive failure signals accrued while `connecting` (0 in every other state). */
  fails: number;
  /**
   * connectingSince is the timestamp `connecting` was entered; the debounce window is measured from
   * it (0 while not `connecting`).
   */
  connectingSince: number;
  /**
   * provisional marks an `offlineNoNetwork` that was ASSUMED at boot (bootOffline), not observed from
   * a real online→offline transition. The first probe confirms it: a success means we were reachable
   * all along, so the machine heals to online SILENTLY (no "back online" toast — there was nothing to
   * come back from); a failure clears the flag, turning it into a confirmed offline whose later
   * recovery does toast. Absent/false in every non-boot snapshot.
   */
  provisional?: boolean;
}

/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
 *  reads `at`, and callers stamp `Date.now()`. */
export interface NetInput {
  /** type is the event. */
  type: NetEvent;
  /** at is the event's timestamp in milliseconds. */
  at: number;
}

/**
 * Effect is a side-effect the reducer asks the O2 store to perform. It is data, not an action — the
 * reducer never runs it.
 * - `toast` — show the offline / back-online toast.
 * - `startProbe` — kick the reachability probe (entering `connecting`, or on an `osOnline` hint).
 * - `showVersionNotice` — raise the "Update / Play offline" notice (entering the version lock).
 * - `setNudge` — latch the dismissible "update available" soft nudge.
 */
export type Effect =
  | { kind: 'toast'; toast: 'offline' | 'online' }
  | { kind: 'startProbe' }
  | { kind: 'showVersionNotice' }
  | { kind: 'setNudge' };

/** NetResult is a reduction: the next snapshot plus the effects the store should perform. */
export interface NetResult {
  /** next is the snapshot after applying the event. */
  next: NetSnapshot;
  /** effects are the side-effects the O2 store should run, in order. */
  effects: Effect[];
}

/**
 * INITIAL is the optimistic pre-boot snapshot: `online` until proven otherwise. The O2 store emits a
 * `boot` event on start, which immediately moves the machine to `connecting` and kicks a probe.
 */
export const INITIAL: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };

function onlineSnapshot(): NetSnapshot {
  return { state: 'online', fails: 0, connectingSince: 0 };
}

function enterConnecting(at: number, fails: number): NetResult {
  return { next: { state: 'connecting', fails, connectingSince: at }, effects: [{ kind: 'startProbe' }] };
}

/**
 * reduce applies one event to the machine and returns the next snapshot plus the effects the store
 * should perform. It is a pure function: it never mutates `prev`, reads the clock, or runs an effect.
 *
 * `boot` and `versionRejected` are handled first because they fire from any state: `boot` resets the
 * machine (the sole exit from the sticky version lock), and `versionRejected` (the hard tier) always
 * supersedes into `offlineVersionLocked`.
 */
export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResult {
  // A fresh app start: reachability is unknown, so enter `connecting` and kick a probe; the probe /
  // version result then resolves online, offlineNoNetwork or offlineVersionLocked. This is the only
  // transition that leaves the sticky version lock (a possibly-newer build).
  if (ev.type === 'boot') {
    return enterConnecting(ev.at, 0);
  }

  // The hard version gate supersedes everything and is sticky. The notice is raised once, on entry;
  // a repeated rejection while already locked changes nothing.
  if (ev.type === 'versionRejected') {
    const effects: Effect[] = prev.state === 'offlineVersionLocked' ? [] : [{ kind: 'showVersionNotice' }];
    return { next: { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 }, effects };
  }

  switch (prev.state) {
    case 'offlineVersionLocked':
      // Sticky: no connectivity event exits it. A probe may reach the gateway, but real calls stay
      // version-gated, so only `boot` / `versionRejected` (both handled above) touch this state.
      return { next: prev, effects: [] };

    case 'online':
      switch (ev.type) {
        case 'callFailed':
        case 'probeFailed':
          // A real failure opens the hysteresis window (probe + timer); this first failure counts.
          return enterConnecting(ev.at, 1);
        case 'osOffline':
          // An OS hint only opens the window and a probe — it never decides offline by itself, so it
          // enters `connecting` with no failure counted.
          return enterConnecting(ev.at, 0);
        case 'versionRecommended':
          // The soft tier nudges only from online; play continues.
          return { next: prev, effects: [{ kind: 'setNudge' }] };
        default:
          // callOk / probeOk / osOnline: already online — idempotent, no effect.
          return { next: prev, effects: [] };
      }

    case 'connecting':
      switch (ev.type) {
        case 'callOk':
        case 'probeOk':
          // Recovery is immediate on the first success — a blip resolves silently (no toast).
          return { next: onlineSnapshot(), effects: [] };
        case 'callFailed':
        case 'probeFailed': {
          const fails = prev.fails + 1;
          const sustained = fails >= cfg.k || ev.at - prev.connectingSince >= cfg.debounceMs;
          if (sustained) {
            return {
              next: { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 },
              effects: [{ kind: 'toast', toast: 'offline' }],
            };
          }
          return { next: { ...prev, fails }, effects: [] };
        }
        case 'osOnline':
          // A positive hint triggers an immediate probe but never flips to online by itself.
          return { next: prev, effects: [{ kind: 'startProbe' }] };
        default:
          // osOffline (already probing) / versionRecommended (no nudge outside online): no-op.
          return { next: prev, effects: [] };
      }

    case 'offlineNoNetwork':
      switch (ev.type) {
        case 'callOk':
        case 'probeOk':
          // The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline
          // that is reachable on its very first probe was never really offline, so heal SILENTLY; a
          // confirmed offline gets the "back online" toast.
          return { next: onlineSnapshot(), effects: prev.provisional ? [] : [{ kind: 'toast', toast: 'online' }] };
        case 'callFailed':
        case 'probeFailed':
          // A failed probe confirms a boot-assumed offline as real (its eventual recovery will then
          // toast); a non-provisional offline is unchanged.
          return prev.provisional ? { next: { ...prev, provisional: false }, effects: [] } : { next: prev, effects: [] };
        case 'osOnline':
          // Trigger a probe; stay offline until it actually wins (a captive portal can report online).
          return { next: prev, effects: [{ kind: 'startProbe' }] };
        default:
          // osOffline (still offline), versionRecommended (no nudge): no-op.
          return { next: prev, effects: [] };
      }
  }

  // Unreachable: the switch above is exhaustive over NetState. Kept so the function is total.
  return { next: prev, effects: [] };
}
