import type { RngStream } from '../core/SeededRNG';
import { drunkStage } from './drunk';
import { CONTRABAND } from '../data/contraband';
import { OUTFIT_VOCAB } from '../data/outfits';
import { ENCOUNTER_SCRIPTS, type EncounterScript } from '../data/strings/encounters';
import type { Archetype, DrunkStage, HeatStrike, Patron } from '../data/types';

// Scripted encounters — design §4.3, the reason this is a game about people and
// not a spot-the-difference puzzle. Two or three of these are injected into the
// generated crowd per night.
//
// The rule the rest of this file exists to serve: doing the human thing costs
// Vibe or risks Heat, and NOTHING anywhere tells the player which call was right.
// There is deliberately no score, no flag, no tally of humane verdicts — if a
// later pass adds one, it has removed the mechanic rather than completed it.
//
// The corollary is just as load-bearing: the humane call must not be secretly
// optimal either. A generous option that quietly pays better is the same lie told
// the other way round. Every `onAdmit` below is priced to be a real decision.

export type EncounterId =
  | 'grabHerMate' | 'quietBeer' | 'lastNight' | 'nightShift'
  | 'eighteenToday' | 'kaydensMate' | 'bigNightOut' | 'twoOfThem';

export interface EncounterBeat {
  line: string;
  /**
   * Delay from the moment the patron REACHES THE ROPE — absolute, not a gap
   * since the previous beat. Read as a gap, a skipped or slow line compresses
   * everything after it and the exchange stops breathing.
   */
  afterMs: number;
}

export interface EncounterOutcome {
  vibeDelta?: number;
  aggroDelta?: number;
  heatStrike?: HeatStrike;
  dazzaText?: string;
  /** shown on the verdict toast — factual, never a moral verdict */
  note?: string;
}

export interface ScriptedEncounter {
  id: EncounterId;
  /** clock-minute window this can be scheduled into */
  window: [number, number];
  archetype: Archetype;
  /** mutates a freshly generated patron into this specific person */
  dress(patron: Patron, rng: RngStream): void;
  beats: EncounterBeat[];
  onAdmit: EncounterOutcome;
  onDeny: EncounterOutcome;
}

/** Minutes of clear air an encounter needs either side of it to land. */
export const ENCOUNTER_SPACING_MIN = 40;

// ---- intoxication bands ------------------------------------------------------

// The dumped bloke has to read as 'loose' at the rope — visible tells, not a
// stumbling mess. Writing 0.55 here would silently become the wrong person the
// day someone retunes drunk.ts, so the band is derived FROM drunkStage instead.
// Scan step is fine enough to find each threshold to the nearest thousandth.
const STAGE_SCAN_STEP = 0.0005;
const BANDS = new Map<DrunkStage, readonly [number, number]>();

function stageBand(stage: DrunkStage): readonly [number, number] {
  const cached = BANDS.get(stage);
  if (cached) return cached;
  let lo = Number.POSITIVE_INFINITY;
  let hi = Number.NEGATIVE_INFINITY;
  for (let i = 0; i * STAGE_SCAN_STEP <= 1; i++) {
    const v = i * STAGE_SCAN_STEP;
    if (drunkStage(v) !== stage) continue;
    lo = Math.min(lo, v);
    hi = Math.max(hi, v);
  }
  if (!Number.isFinite(lo)) throw new Error(`encounters: no intoxication maps to '${stage}'`);
  const band = [lo, hi] as const;
  BANDS.set(stage, band);
  return band;
}

/**
 * A value comfortably inside `stage`'s band. Inset from both edges because the
 * floor sim drifts intoxication upward: a patron parked on a threshold restages
 * on the first tick, and the script's whole point is the stage they arrive in.
 */
function intoxicationIn(stage: DrunkStage, rng: RngStream): number {
  const [lo, hi] = stageBand(stage);
  const inset = (hi - lo) * 0.15;
  return lo + inset + rng.next() * (hi - lo - 2 * inset);
}

// ---- shared dressing ---------------------------------------------------------

const FLASK = CONTRABAND.find((c) => c.id === 'flask');
if (!FLASK) throw new Error("encounters: contraband has no 'flask' — lastNight has nothing to carry");

const POLO = OUTFIT_VOCAB.top.find((d) => d.type === 'polo' && d.canLogo === true);
if (!POLO) {
  throw new Error("encounters: outfit vocab has no logo-capable 'polo' — nightShift is the work shirt");
}

/**
 * Give a scripted person a boring, valid licence.
 *
 * `punter` carries a 2% fake-ID roll, and a scripted moral call that turns into
 * an ID puzzle is neither. dress() has no nightDate to recompute an expiry from,
 * so the existing one is pushed five years past whatever the generator produced —
 * which clears the 'expired' tell whichever side of tonight it started on.
 */
function settleId(patron: Patron): void {
  delete patron.idCard.fake;
  patron.idCard.photoSeed = patron.dollSeed;
  patron.idCard.expiry = plusFiveYears(patron.idCard.expiry);
}

const plusFiveYears = (iso: string): string => {
  const [y = '2026', m = '01', d = '01'] = iso.slice(0, 10).split('-');
  // Clamped to the 28th so a 29 February expiry cannot be pushed onto a date that
  // does not exist, which parses as NaN and quietly reads as "never expires".
  const day = Math.min(Number(d), 28);
  return `${Number(y) + 5}-${m}-${String(day).padStart(2, '0')}`;
};

/** Everything every scripted patron needs. dollSeed is untouched on purpose: the
 * paper doll should still be a random stranger, or the player learns the faces. */
function baseDress(patron: Patron, rng: RngStream, script: EncounterScript): void {
  settleId(patron);
  patron.idCard.name = rng.pick(script.names);
  // Same reasoning as clearContraband. The guest-list clipboard fires a
  // "'m on the list" step-up line off claimsGuestList, which would talk over
  // the scripted beats — and "i was inside ten minutes ago" plus a guest-list
  // claim is two different people standing in one pair of shoes.
  delete patron.flags.claimsGuestList;
  delete patron.flags.onGuestList;
}

/** Scripted dilemmas are about the one thing they are about. A stray baggie from
 * the archetype roll turns the girl at the rope into a pat-down instead. */
function clearContraband(patron: Patron): void {
  delete patron.flags.contraband;
}

const script = (id: EncounterId): EncounterScript => {
  const s = ENCOUNTER_SCRIPTS[id];
  if (!s) throw new Error(`encounters: no dialogue script for '${id}'`);
  return s;
};

const beatsOf = (id: EncounterId): EncounterBeat[] =>
  script(id).beats.map((b) => ({ line: b.line, afterMs: b.afterMs }));

// ---- the encounters ----------------------------------------------------------

export const ENCOUNTERS: readonly ScriptedEncounter[] = [
  {
    // Early: he is doing the rounds while he can still stand at a bar and talk.
    id: 'lastNight',
    window: [30, 150],
    archetype: 'regular',
    dress(patron, rng) {
      const s = script('lastNight');
      baseDress(patron, rng, s);
      // He TELLS you about the flask. That is the trap — a hip flask you were
      // told about is a licence problem you chose, not one you missed.
      patron.flags.contraband = [FLASK.id];
      patron.intoxication = intoxicationIn('tipsy', rng);
      // Deliberately no timesDeniedBefore: judge() prices a grudged regular at
      // -5 Vibe on deny, which would tilt this straight back toward admitting.
      // The knock-back in his second beat happened years before this run.
    },
    beats: beatsOf('lastNight'),
    onAdmit: {
      vibeDelta: 2,
      heatStrike: { reason: 'BYO spirits admitted — hip flask carried in on the door', deferred: true },
      note: script('lastNight').admitNote,
    },
    onDeny: { note: script('lastNight').denyNote },
  },
  {
    id: 'quietBeer',
    window: [90, 210],
    archetype: 'quietMessy',
    dress(patron, rng) {
      const s = script('quietBeer');
      baseDress(patron, rng, s);
      clearContraband(patron);
      // 'loose' exactly: the tells are visible, so the player cannot claim later
      // that they could not tell. quietMessy's low tolerance does the rest.
      patron.intoxication = intoxicationIn('loose', rng);
    },
    beats: beatsOf('quietBeer'),
    onAdmit: {
      // The real cost is not this number. It is a loose quietMessy on the floor
      // with a reason to keep going, which the floor sim charges for later.
      vibeDelta: -1,
      dazzaText: script('quietBeer').admitDazza,
      note: script('quietBeer').admitNote,
    },
    onDeny: { note: script('quietBeer').denyNote },
  },
  {
    // After the logo rule lands at 90 — and BEFORE Dazza rescinds it (the rule
    // cap retires noLogos when the 200-minute rule arrives). Outside that band
    // he is just a bloke in a polo and the dilemma evaporates.
    id: 'nightShift',
    window: [150, 195],
    archetype: 'punter',
    dress(patron, rng) {
      const s = script('nightShift');
      baseDress(patron, rng, s);
      clearContraband(patron);
      patron.intoxication = intoxicationIn('sober', rng);
      // Mutated in place rather than pushed, so the outfit keeps exactly one
      // layer per slot. The generator always fills top; the guard is for a
      // hand-built patron in a test.
      const top = patron.outfit.find((l) => l.slot === 'top');
      if (top) {
        top.type = POLO.type;
        top.colour = rng.pick(POLO.colours);
        top.logo = true;
      }
    },
    beats: beatsOf('nightShift'),
    onAdmit: {
      // No numbers here on purpose. He is a live noLogos breach, so admitting
      // him is already priced by judge() — Dazza does a lap and finds him. A
      // bespoke penalty on top would charge the player twice for one call.
      note: script('nightShift').admitNote,
    },
    onDeny: { note: script('nightShift').denyNote },
  },
  {
    // Late, when the room is at the number and the stamp actually matters.
    id: 'grabHerMate',
    window: [210, 330],
    archetype: 'punter',
    dress(patron, rng) {
      const s = script('grabHerMate');
      baseDress(patron, rng, s);
      clearContraband(patron);
      // The whole dilemma in one flag. She stepped out without one and says so.
      patron.flags.uvStamped = false;
      patron.intoxication = intoxicationIn('tipsy', rng);
    },
    beats: beatsOf('grabHerMate'),
    onAdmit: {
      vibeDelta: -2,
      heatStrike: {
        // Worded off what THIS module knows — she has no stamp and the encounter
        // set that flag itself. It deliberately does NOT mention capacity: the
        // encounter cannot see insideCount, so a capacity-worded reason is a lie
        // whenever the room is half empty, and a duplicate of judge()'s own
        // capacity strike whenever it isn't. One admit, one strike, true reason.
        reason: 'unstamped re-entry admitted — nothing on the door to say she was inside',
        deferred: true,
      },
      dazzaText: script('grabHerMate').admitDazza,
      note: script('grabHerMate').admitNote,
    },
    // Free. Costs nothing but the moment, and the moment is not a mechanic.
    onDeny: { note: script('grabHerMate').denyNote },
  },
  {
    // Early: it went midnight forty minutes ago and he has rehearsed this.
    id: 'eighteenToday',
    window: [60, 190],
    archetype: 'fresh18',
    dress(patron, rng) {
      const s = script('eighteenToday');
      baseDress(patron, rng, s);
      clearContraband(patron);
      patron.intoxication = intoxicationIn('sober', rng);
    },
    beats: beatsOf('eighteenToday'),
    // No numbers either way. He is a clean legal admit and judge() already pays
    // the ordinary rate; denying him is the conscience test judgeDeny narrates.
    onAdmit: { note: script('eighteenToday').admitNote },
    onDeny: { note: script('eighteenToday').denyNote },
  },
  {
    // Kayden has been making promises again.
    id: 'kaydensMate',
    window: [120, 250],
    archetype: 'punter',
    dress(patron, rng) {
      const s = script('kaydensMate');
      baseDress(patron, rng, s);
      clearContraband(patron);
      patron.intoxication = intoxicationIn('tipsy', rng);
    },
    beats: beatsOf('kaydensMate'),
    onAdmit: {
      // Small and real: you honoured an authority that does not exist.
      vibeDelta: -1,
      dazzaText: script('kaydensMate').admitDazza,
      note: script('kaydensMate').admitNote,
    },
    onDeny: { note: script('kaydensMate').denyNote },
  },
  {
    // The buck's straggler. Sober-adjacent, desperate to be present.
    id: 'bigNightOut',
    window: [180, 300],
    archetype: 'punter',
    dress(patron, rng) {
      const s = script('bigNightOut');
      baseDress(patron, rng, s);
      clearContraband(patron);
      // 'loose' on two lemonades and a recovery: the tells are real even if his
      // story is true. RSA does not care about the wedding.
      patron.intoxication = intoxicationIn('loose', rng);
    },
    beats: beatsOf('bigNightOut'),
    onAdmit: {
      // The floor prices a loose admission already (judge ripen); this is the
      // chant getting his name in it.
      vibeDelta: 1,
      note: script('bigNightOut').admitNote,
    },
    onDeny: { note: script('bigNightOut').denyNote },
  },
  {
    // Engaged tonight, still holding hands, fully briefed on the rule.
    id: 'twoOfThem',
    window: [240, 345],
    archetype: 'punter',
    dress(patron, rng) {
      const s = script('twoOfThem');
      baseDress(patron, rng, s);
      clearContraband(patron);
      patron.intoxication = intoxicationIn('tipsy', rng);
      // A live noHandHolders breach the whole window (the rule survives the cap
      // from 200 onward). judge() prices the admit; the beats price the deny.
      patron.flags.handHolding = true;
    },
    beats: beatsOf('twoOfThem'),
    onAdmit: { note: script('twoOfThem').admitNote },
    onDeny: { note: script('twoOfThem').denyNote },
  },
];

for (const id of Object.keys(ENCOUNTER_SCRIPTS)) {
  if (!ENCOUNTERS.some((e) => e.id === id)) {
    throw new Error(`encounters: dialogue script '${id}' belongs to no encounter`);
  }
}

// With eight overlapping windows a whole-set worst-case chain no longer fits in
// one night (that guarantee died when the table outgrew a single night, by
// design — a week should not see the same four people). The load-time check now
// guards each window's own sanity; the scheduler handles infeasible DRAWS by
// skipping the pick (a 2-encounter night beats a crashed one).
function assertWindowsSane(): void {
  for (const e of ENCOUNTERS) {
    if (e.window[0] >= e.window[1] || e.window[0] < 0 || e.window[1] > 350) {
      throw new Error(`encounters: window for '${e.id}' is not a usable night interval`);
    }
  }
}
assertWindowsSane();

export function encounterById(id: EncounterId): ScriptedEncounter | undefined {
  return ENCOUNTERS.find((e) => e.id === id);
}

/**
 * Pick `count` distinct encounters and place each at a random minute inside its
 * own window, at least ENCOUNTER_SPACING_MIN apart and sorted by time. Asking for
 * more than exist returns all of them rather than throwing — a night that wants
 * five encounters should be short, not broken.
 */
export function scheduleEncounters(
  rng: RngStream,
  count: number,
  /** Encounter ids already met this run — a week should meet new people first. */
  exclude: ReadonlySet<string> = new Set(),
): Array<{ atMin: number; id: EncounterId }> {
  const wanted = Math.min(Math.max(0, Math.floor(count)), ENCOUNTERS.length);
  if (wanted === 0) return [];

  const shuffle = (arr: ScriptedEncounter[]): ScriptedEncounter[] => {
    for (let i = arr.length - 1; i > 0; i--) {
      const j = rng.int(0, i);
      const a = arr[i]!;
      arr[i] = arr[j]!;
      arr[j] = a;
    }
    return arr;
  };

  // Fresh faces first; once the run has met everyone, repeats are honest
  // (a regular being a regular), so the excluded set tops the pool back up.
  const fresh = shuffle(ENCOUNTERS.filter((e) => !exclude.has(e.id)));
  const met = shuffle(ENCOUNTERS.filter((e) => exclude.has(e.id)));
  const pool = [...fresh, ...met];

  // Three passes, so a random draw can never strand a later pick:
  //  1. end-order greedy at the EARLIEST legal minute — anything that fails
  //     here genuinely does not fit tonight and is dropped (a 2-encounter
  //     night beats a crashed one);
  //  2. a backward pass computing each survivor's latest SAFE minute (its own
  //     window end, capped by the next pick's latest minute less the spacing);
  //  3. a forward pass drawing the actual minute inside [earliest, latestSafe]
  //     — random where there is room, exact where there is not.
  const chosen = pool.slice(0, wanted).sort((a, b) => a.window[1] - b.window[1]);

  const placed: ScriptedEncounter[] = [];
  let probe = Number.NEGATIVE_INFINITY;
  for (const e of chosen) {
    const lo = Math.max(e.window[0], probe);
    if (lo > e.window[1]) continue;
    placed.push(e);
    probe = lo + ENCOUNTER_SPACING_MIN;
  }

  const latest: number[] = new Array(placed.length);
  for (let i = placed.length - 1; i >= 0; i--) {
    const cap = i === placed.length - 1 ? Infinity : latest[i + 1]! - ENCOUNTER_SPACING_MIN;
    latest[i] = Math.min(placed[i]!.window[1], cap);
  }

  const out: Array<{ atMin: number; id: EncounterId }> = [];
  let earliest = Number.NEGATIVE_INFINITY;
  for (let i = 0; i < placed.length; i++) {
    const e = placed[i]!;
    const lo = Math.max(e.window[0], earliest);
    const atMin = rng.int(lo, Math.max(lo, latest[i]!));
    out.push({ atMin, id: e.id });
    earliest = atMin + ENCOUNTER_SPACING_MIN;
  }
  return out.sort((a, b) => a.atMin - b.atMin);
}
