import { describe, expect, it, vi } from 'vitest';

// shouldRecordStrike is pure but lives in a module that subclasses Phaser.Scene,
// and Phaser touches `window` at import time under the node environment. Same
// stub the night-summary tests use.
vi.mock('phaser', () => ({ default: { Scene: class {} } }));

import { freshNightState } from '../src/core/meters';
import type { NightState } from '../src/data/types';
import { MAX_HEAT_STRIKES, shouldRecordStrike } from '../src/scenes/door/NightScene';
import {
  INSPECTOR_LINES,
  INSPECTOR_TUNING,
  breachStrikes,
  hasLeft,
  isWatching,
  observe,
  revealLine,
  startWatch,
  type VenueBreaches,
} from '../src/rules/inspector';

const NONE: VenueBreaches = { overCapacity: false, maggotOnFloor: false, underageInside: false };
const ALL: VenueBreaches = { overCapacity: true, maggotOnFloor: true, underageInside: true };

const breach = (over: Partial<VenueBreaches>): VenueBreaches => ({ ...NONE, ...over });

const ADMITTED = 100;
const watch = startWatch('p42', ADMITTED);
const LEAVES = watch.leavesAtMin;

describe('the watch window', () => {
  it('runs exactly INSPECTOR_TUNING.watchMin from the minute they were admitted', () => {
    expect(watch.patronId).toBe('p42');
    expect(watch.admittedAtMin).toBe(ADMITTED);
    expect(LEAVES - watch.admittedAtMin).toBe(INSPECTOR_TUNING.watchMin);
  });

  // Both bounds pinned explicitly: an off-by-one here either hands the player a
  // free minute or steals one, and the strike it decides costs the run.
  it('is watching ON the admitting minute and NOT on the leaving minute', () => {
    expect(isWatching(watch, ADMITTED)).toBe(true);
    expect(isWatching(watch, LEAVES - 1)).toBe(true);
    expect(isWatching(watch, LEAVES)).toBe(false);
  });

  it('is not watching before they were admitted, or long after they left', () => {
    expect(isWatching(watch, ADMITTED - 1)).toBe(false);
    expect(isWatching(watch, LEAVES + 60)).toBe(false);
    expect(isWatching(null, ADMITTED)).toBe(false);
  });

  it('hasLeft flips on the leaving minute and stays true', () => {
    expect(hasLeft(watch, LEAVES - 1)).toBe(false);
    expect(hasLeft(watch, LEAVES)).toBe(true);
    expect(hasLeft(watch, LEAVES + 1)).toBe(true);
    expect(hasLeft(null, LEAVES)).toBe(false);
  });
});

describe('breach strikes', () => {
  it('files nothing when the room is clean', () => {
    expect(breachStrikes(NONE)).toEqual([]);
  });

  it('files one strike per live breach', () => {
    expect(breachStrikes(breach({ overCapacity: true }))).toHaveLength(1);
    expect(breachStrikes(breach({ maggotOnFloor: true, underageInside: true }))).toHaveLength(2);
    expect(breachStrikes(ALL)).toHaveLength(3);
  });

  it('marks every strike immediate, never deferred to the 3 AM audit', () => {
    for (const s of breachStrikes(ALL)) expect(s.deferred).toBe(false);
  });

  // NightScene dedupes strikes by reason, so these two properties are what stop
  // a 30-minute capacity breach filing 30 strikes, or two breaches collapsing
  // into one.
  it('gives distinct breaches distinct reasons', () => {
    const reasons = breachStrikes(ALL).map((s) => s.reason);
    expect(new Set(reasons).size).toBe(reasons.length);
    for (const r of reasons) expect(r.length).toBeGreaterThan(0);
  });

  // Counts and distinctness alone let the whole mapping be scrambled silently:
  // the room is merely over capacity and the summary accuses you of admitting a
  // minor. Each breach is pinned to its own reason by content, not position.
  it('names the breach that actually happened', () => {
    const only = (over: Partial<VenueBreaches>): string => {
      const strikes = breachStrikes(breach(over));
      expect(strikes).toHaveLength(1);
      return strikes[0]!.reason;
    };
    expect(only({ overCapacity: true })).toMatch(/capacity/i);
    expect(only({ maggotOnFloor: true })).toMatch(/maggot/i);
    expect(only({ underageInside: true })).toMatch(/minor|underage/i);

    // ...and the multi-breach path keeps each reason attached to its own breach.
    expect(breachStrikes(breach({ overCapacity: true, underageInside: true }))).toEqual([
      { reason: only({ overCapacity: true }), deferred: false },
      { reason: only({ underageInside: true }), deferred: false },
    ]);
  });

  it('gives the same breach the same reason on consecutive minutes', () => {
    const live = breach({ overCapacity: true });
    const a = observe(watch, ADMITTED + 5, live).strikes.map((s) => s.reason);
    const b = observe(watch, ADMITTED + 6, live).strikes.map((s) => s.reason);
    expect(a).toEqual(b);
    expect(a).toHaveLength(1);
  });
});

describe('observe', () => {
  it('is silent while watching a clean room', () => {
    expect(observe(watch, ADMITTED, NONE)).toEqual({ strikes: [], watchEnded: false });
  });

  it('strikes for every breach live while they are inside', () => {
    const res = observe(watch, ADMITTED + 10, ALL);
    expect(res.strikes).toHaveLength(3);
    expect(res.watchEnded).toBe(false);
    expect(res.dazzaText).toBeUndefined();
  });

  it('reveals exactly once, on the leaving minute, and never strikes again', () => {
    let reveals = 0;
    let strikes = 0;
    // Ticked the way NightScene ticks it: one call per whole clock minute,
    // breaching the entire time, well past the point the inspector left.
    for (let min = ADMITTED - 5; min <= LEAVES + 30; min++) {
      const res = observe(watch, min, ALL);
      if (res.watchEnded) reveals++;
      if (min > LEAVES) {
        expect(res.strikes).toEqual([]);
        expect(res.dazzaText).toBeUndefined();
      }
      strikes += res.strikes.length;
    }
    expect(reveals).toBe(1);
    expect(strikes).toBe(3 * INSPECTOR_TUNING.watchMin);
  });

  it('carries the reveal text on the ending minute only', () => {
    const ending = observe(watch, LEAVES, ALL);
    expect(ending.watchEnded).toBe(true);
    expect(ending.strikes).toEqual([]);
    expect(ending.dazzaText ?? '').not.toBe('');
  });

  it('is inert with no watch at all — a denied inspector never comes back', () => {
    for (const min of [0, ADMITTED, LEAVES, 360]) {
      expect(observe(null, min, ALL)).toEqual({ strikes: [], watchEnded: false });
    }
  });

  it('is deterministic — same watch, same minute, same breaches, same result', () => {
    const again = startWatch('p42', ADMITTED);
    for (let min = ADMITTED; min <= LEAVES + 2; min++) {
      expect(observe(again, min, ALL)).toEqual(observe(watch, min, ALL));
    }
  });
});

// observe() returns one strike per breach PER MINUTE — 30 objects for a single
// breach held across the window. Nothing on the bus collapses those: meters.ts
// pushes every heat:strike it hears, and NightScene ends the run on the third.
// The collapse is `shouldRecordStrike`, a guard the emitting site must call. If
// an integrator emits observe()'s strikes raw, a sustained breach pulls the
// licence in three clock minutes. These tests pin the contract end to end.
describe('wiring through shouldRecordStrike (the flood guard)', () => {
  /** What NightScene does per emit: guard, then record. Returns strikes landed. */
  const runWatch = (breaches: VenueBreaches): NightState => {
    const state = freshNightState('royal', 0, 120);
    for (let min = ADMITTED; min <= LEAVES + 5; min++) {
      for (const s of observe(watch, min, breaches).strikes) {
        if (shouldRecordStrike(state, s)) state.heatStrikes.push(s);
      }
    }
    return state;
  };

  it('collapses a breach held for the whole window to ONE strike', () => {
    const raw = observe(watch, ADMITTED, breach({ overCapacity: true })).strikes.length;
    expect(raw).toBe(1); // per minute...
    expect(runWatch(breach({ overCapacity: true })).heatStrikes).toHaveLength(1); // ...once overall
  });

  it('files one strike per distinct breach, not one per breach-minute', () => {
    expect(runWatch(ALL).heatStrikes).toHaveLength(3);
  });

  it('never exceeds MAX_HEAT_STRIKES, so a watched breach cannot flood the run', () => {
    expect(runWatch(ALL).heatStrikes.length).toBeLessThanOrEqual(MAX_HEAT_STRIKES);
  });

  it('emits far more raw strikes than land — the guard is load-bearing, not optional', () => {
    let raw = 0;
    for (let min = ADMITTED; min <= LEAVES + 5; min++) raw += observe(watch, min, ALL).strikes.length;
    expect(raw).toBe(3 * INSPECTOR_TUNING.watchMin);
    expect(raw).toBeGreaterThan(MAX_HEAT_STRIKES);
  });
});

describe('the reveal lines', () => {
  it('returns a real line for rolls 0, 0.5 and 1', () => {
    for (const roll of [0, 0.5, 1]) {
      expect(INSPECTOR_LINES).toContain(revealLine(roll));
      expect(revealLine(roll).length).toBeGreaterThan(0);
    }
  });

  it('falls back rather than returning nothing for a junk roll', () => {
    for (const roll of [-1, 2, Number.NaN]) {
      expect(INSPECTOR_LINES).toContain(revealLine(roll));
    }
  });
});
