import { describe, it, expect } from 'vitest';
import {
  reduce,
  INITIAL,
  type NetConfig,
  type NetEvent,
  type NetInput,
  type NetSnapshot,
} from './netstate';

// A deterministic config for the assertions: k = 3 (a single blip rides out; three consecutive
// failures trip offline) and a 1 s debounce window (the wall-clock anti-flap branch).
const cfg: NetConfig = { k: 3, debounceMs: 1000 };

// Event and snapshot builders keep the cases terse; `at` defaults to 0 where the debounce branch is
// irrelevant.
const e = (type: NetEvent, at = 0): NetInput => ({ type, at });
const online: NetSnapshot = { state: 'online', fails: 0, connectingSince: 0 };
const offline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
const locked: NetSnapshot = { state: 'offlineVersionLocked', fails: 0, connectingSince: 0 };
const connecting = (over: Partial<NetSnapshot> = {}): NetSnapshot => ({
  state: 'connecting',
  fails: 0,
  connectingSince: 0,
  ...over,
});

// fold applies a sequence of inputs, threading the snapshot and collecting every emitted effect —
// the shape most edge cases assert against.
function fold(start: NetSnapshot, inputs: NetInput[], config: NetConfig) {
  let snap = start;
  const effects = [];
  for (const ev of inputs) {
    const r = reduce(snap, ev, config);
    snap = r.next;
    effects.push(...r.effects);
  }
  return { final: snap, effects };
}

describe('reduce — transition table', () => {
  it('online + callFailed → connecting (starts a probe, counts the failure)', () => {
    const r = reduce(online, e('callFailed', 100), cfg);
    expect(r.next).toEqual({ state: 'connecting', fails: 1, connectingSince: 100 });
    expect(r.effects).toEqual([{ kind: 'startProbe' }]);
  });

  it('online + osOffline → connecting (starts a probe, no failure counted — a hint)', () => {
    const r = reduce(online, e('osOffline', 100), cfg);
    expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
    expect(r.effects).toEqual([{ kind: 'startProbe' }]);
  });

  it('connecting + probeOk → online (silent; a blip must not thrash the chrome)', () => {
    const r = reduce(connecting({ fails: 2, connectingSince: 100 }), e('probeOk', 200), cfg);
    expect(r.next).toEqual(online);
    expect(r.effects).toEqual([]);
  });

  it('connecting + callOk → online (silent)', () => {
    const r = reduce(connecting({ fails: 1, connectingSince: 100 }), e('callOk', 200), cfg);
    expect(r.next).toEqual(online);
    expect(r.effects).toEqual([]);
  });

  it('connecting + a failure reaching K → offlineNoNetwork (toast "offline")', () => {
    const r = reduce(connecting({ fails: cfg.k - 1, connectingSince: 0 }), e('probeFailed', 10), cfg);
    expect(r.next).toEqual({ state: 'offlineNoNetwork', fails: 0, connectingSince: 0 });
    expect(r.effects).toEqual([{ kind: 'toast', toast: 'offline' }]);
  });

  it('offlineNoNetwork + osOnline → stays offline, triggers a probe (a hint never flips by itself)', () => {
    const r = reduce(offline, e('osOnline', 100), cfg);
    expect(r.next).toEqual(offline);
    expect(r.effects).toEqual([{ kind: 'startProbe' }]);
  });

  it('offlineNoNetwork + probeOk → online (toast "back online")', () => {
    const r = reduce(offline, e('probeOk', 100), cfg);
    expect(r.next).toEqual(online);
    expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
  });

  it('offlineNoNetwork + callOk → online (toast "back online")', () => {
    const r = reduce(offline, e('callOk', 100), cfg);
    expect(r.next).toEqual(online);
    expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
  });

  it('any state + versionRejected → offlineVersionLocked (shows the notice once)', () => {
    for (const start of [online, connecting({ fails: 1, connectingSince: 5 }), offline]) {
      const r = reduce(start, e('versionRejected', 100), cfg);
      expect(r.next).toEqual({ state: 'offlineVersionLocked', fails: 0, connectingSince: 0 });
      expect(r.effects).toEqual([{ kind: 'showVersionNotice' }]);
    }
  });

  it('offlineVersionLocked + versionRejected (probe still rejected) → stays locked, no repeat notice', () => {
    const r = reduce(locked, e('versionRejected', 100), cfg);
    expect(r.next).toEqual(locked);
    expect(r.effects).toEqual([]);
  });

  it('online + versionRecommended → online + a nudge', () => {
    const r = reduce(online, e('versionRecommended', 100), cfg);
    expect(r.next).toEqual(online);
    expect(r.effects).toEqual([{ kind: 'setNudge' }]);
  });

  it('boot → connecting + startProbe from any state (resets the sticky lock)', () => {
    for (const start of [online, offline, locked, connecting({ fails: 2, connectingSince: 5 })]) {
      const r = reduce(start, e('boot', 100), cfg);
      expect(r.next).toEqual({ state: 'connecting', fails: 0, connectingSince: 100 });
      expect(r.effects).toEqual([{ kind: 'startProbe' }]);
    }
  });
});

describe('reduce — hysteresis (blip vs sustained)', () => {
  it('a single fail then a success rides out inside connecting (no offline toast)', () => {
    const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 200)], cfg);
    expect(final).toEqual(online);
    expect(effects).toEqual([{ kind: 'startProbe' }]);
  });

  it('K consecutive failures trip offlineNoNetwork', () => {
    const { final, effects } = fold(online, [e('callFailed', 0), e('probeFailed', 10), e('probeFailed', 20)], cfg);
    expect(final.state).toBe('offlineNoNetwork');
    expect(effects).toContainEqual({ kind: 'toast', toast: 'offline' });
  });

  it('the debounce window trips offlineNoNetwork even below K', () => {
    const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs + 1)], cfg);
    expect(final.state).toBe('offlineNoNetwork');
  });

  it('a failure inside the debounce window and below K stays connecting', () => {
    const { final } = fold(online, [e('osOffline', 0), e('probeFailed', cfg.debounceMs - 1)], cfg);
    expect(final.state).toBe('connecting');
    expect(final.fails).toBe(1);
  });
});

describe('reduce — version gate (two tiers)', () => {
  it('versionRecommended sets the nudge only from online', () => {
    expect(reduce(online, e('versionRecommended', 0), cfg).effects).toEqual([{ kind: 'setNudge' }]);
    for (const start of [connecting({ connectingSince: 0 }), offline, locked]) {
      expect(reduce(start, e('versionRecommended', 0), cfg).effects).toEqual([]);
    }
  });

  it('versionRejected from any state locks (the hard tier), superseding a pending soft nudge', () => {
    const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
    expect(final.state).toBe('offlineVersionLocked');
    expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
  });
});

describe('reduce — edge cases (#1–#12)', () => {
  it('#1 rapid flap (fail→ok inside the window) never leaves connecting for offline', () => {
    const { final, effects } = fold(online, [e('callFailed', 0), e('callOk', 50)], cfg);
    expect(final).toEqual(online);
    expect(effects).not.toContainEqual({ kind: 'toast', toast: 'offline' });
  });

  it('#2 cold boot, no network → offlineNoNetwork', () => {
    const { final } = fold(
      INITIAL,
      [e('boot', 0), e('osOffline', 1), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
      cfg,
    );
    expect(final.state).toBe('offlineNoNetwork');
  });

  it('#3 cold boot, gateway up but version < min → offlineVersionLocked + notice', () => {
    const { final, effects } = fold(INITIAL, [e('boot', 0), e('versionRejected', 20)], cfg);
    expect(final.state).toBe('offlineVersionLocked');
    expect(effects).toContainEqual({ kind: 'showVersionNotice' });
  });

  it('#4 cold boot, session-less guest: the reconcile IS the probe — ok→online, fail→offline', () => {
    expect(fold(INITIAL, [e('boot', 0), e('callOk', 20)], cfg).final).toEqual(online);
    const failed = fold(
      INITIAL,
      [e('boot', 0), e('probeFailed', 10), e('probeFailed', 20), e('probeFailed', 30)],
      cfg,
    );
    expect(failed.final.state).toBe('offlineNoNetwork');
  });

  it('#5 mid-session min-version bump → next call versionRejected → locked mid-play', () => {
    const r = reduce(online, e('versionRejected', 100), cfg);
    expect(r.next.state).toBe('offlineVersionLocked');
    expect(r.effects).toContainEqual({ kind: 'showVersionNotice' });
  });

  it('#6 captive portal: osOnline fires but the gateway is down → probe fails → stays offline', () => {
    const { final, effects } = fold(offline, [e('osOnline', 0), e('probeFailed', 10)], cfg);
    expect(final.state).toBe('offlineNoNetwork');
    expect(effects).toEqual([{ kind: 'startProbe' }]);
  });

  it('#7 recovery race: probeOk then a queued callOk → one idempotent transition to online', () => {
    const { final, effects } = fold(offline, [e('probeOk', 0), e('callOk', 1)], cfg);
    expect(final).toEqual(online);
    expect(effects.filter((x) => x.kind === 'toast')).toEqual([{ kind: 'toast', toast: 'online' }]);
  });

  it('#8 soft nudge then a hard reject → escalates to locked (notice after the nudge)', () => {
    const { final, effects } = fold(online, [e('versionRecommended', 0), e('versionRejected', 10)], cfg);
    expect(final.state).toBe('offlineVersionLocked');
    expect(effects).toEqual([{ kind: 'setNudge' }, { kind: 'showVersionNotice' }]);
  });

  it('#9 offlineVersionLocked is sticky — no connectivity event exits it; only boot does', () => {
    const stuck: NetEvent[] = ['callOk', 'probeOk', 'osOnline', 'osOffline', 'callFailed', 'probeFailed', 'versionRecommended'];
    for (const ev of stuck) {
      expect(reduce(locked, e(ev, 100), cfg).next).toEqual(locked);
    }
    expect(reduce(locked, e('boot', 100), cfg).next.state).toBe('connecting');
  });

  it('#10 offline → back online transitions cleanly (local-game persistence is O5, not the machine)', () => {
    expect(fold(offline, [e('probeOk', 0)], cfg).final).toEqual(online);
  });

  it('#11 nothing forces the machine offline without a real failure/version signal (a stale pref cannot stick)', () => {
    expect(reduce(INITIAL, e('boot', 0), cfg).next.state).toBe('connecting');
    const { final } = fold(
      INITIAL,
      [e('boot', 0), e('callOk', 10), e('osOnline', 20), e('versionRecommended', 30)],
      cfg,
    );
    expect(final.state).toBe('online');
  });

  it('#12 TG/VK inertness: with only success/hint events the machine never leaves online', () => {
    const { final, effects } = fold(online, [e('callOk', 0), e('probeOk', 10), e('osOnline', 20), e('callOk', 30)], cfg);
    expect(final).toEqual(online);
    expect(effects).toEqual([]);
  });
});

describe('reduce — purity', () => {
  it('does not mutate the previous snapshot', () => {
    const prev = Object.freeze<NetSnapshot>({ state: 'connecting', fails: 1, connectingSince: 5 });
    expect(() => reduce(prev, e('probeFailed', 10), cfg)).not.toThrow();
    expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
  });
});

// A boot-assumed (provisional) offline — bootOffline sets this — heals SILENTLY on its first
// successful probe (a first launch that actually had connectivity must not flash "back online"),
// while a failed probe confirms it as a real offline whose later recovery does toast.
describe('provisional (boot-assumed) offline', () => {
  const bootOffline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };

  it('heals to online with NO toast when the first probe succeeds', () => {
    const r = reduce(bootOffline, e('probeOk'), cfg);
    expect(r.next.state).toBe('online');
    expect(r.effects).toEqual([]); // no "back online" toast — it was never really offline
  });

  it('clears provisional on a failed probe, then toasts on the eventual recovery', () => {
    const failed = reduce(bootOffline, e('probeFailed'), cfg);
    expect(failed.next.state).toBe('offlineNoNetwork');
    expect(failed.next.provisional).toBe(false);
    expect(failed.effects).toEqual([]);
    const healed = reduce(failed.next, e('probeOk'), cfg);
    expect(healed.next.state).toBe('online');
    expect(healed.effects).toEqual([{ kind: 'toast', toast: 'online' }]); // now a real recovery
  });

  it('a non-provisional (observed) offline still toasts on recovery', () => {
    const r = reduce(offline, e('probeOk'), cfg);
    expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
  });
});
