import { violations, type DoorDressCodeRule } from './dressCode';
import { idVerdict, type IdVerdict } from './idCheck';
import type { DeferredHit, DoorOutcome, JudgeContext } from './doorTypes';
import { lapLine, ripenLine } from '../data/strings/door';
import { CONTRABAND } from '../data/contraband';
import { drunkStage } from './drunk';
import type { Patron, Verdict } from '../data/types';

// The scoring function — the one place this game's morality is a number. Every
// branch below is a line of the design doc's tension table (§2 meters, §4.3 the
// moral undertow), and every magnitude lives in JUDGE_TUNING so feel can be
// tuned without archaeology.
//
// The load-bearing asymmetry, which is NOT a bug: denying a clean patron still
// pays vibe, it just costs far more aggro than denying a real violator does.
// Arbitrary power is rewarded and expensive at the same time. That is the game.

export const JUDGE_TUNING = {
  /** Deny someone who genuinely broke something. The job, done right — and
   * routine. The room expects it; the payoff is small (tuning pass 2026-07-19:
   * was 6, which made denial the vibe engine and emptied the room). */
  denyViolatorVibe: 2,
  /** The queue still watched a stranger get humiliated. */
  denyViolatorAggro: 1,

  /** Deny someone with nothing wrong with them. Small reward... */
  denyCleanVibe: 1,
  /** ...large heat. The whole line saw that there was no reason. */
  denyCleanAggro: 7,

  /** Deny a bloke who really does drink with the owner. The worst call available. */
  denyOwnersMateVibe: -14,
  denyOwnersMateAggro: 4,

  /** Influencer denials, stacked on top of whichever deny applied. */
  denyInfluencerVibe: 3,
  denyInfluencerAggro: 4,

  /** A regular you have knocked back before. He kept count. */
  denyGrudgedRegularVibe: -5,

  admitCleanVibe: 2,
  admitCleanAggro: -3,
  /** Waving a violator through moves the queue too, just less — you hesitated. */
  admitViolatorAggro: -2,
  /** The trap: a dud ID admitted still reads to the queue as a fast door. */
  admitBadIdVibe: 1,

  /** "Dazza does a lap": the breach lands 2..5 minutes after you could argue. */
  lapDelayMin: 2,
  lapDelaySpread: 3,
  lapVibePerRule: -4,
  lapVibeCap: -12,

  /** v0.1 has no roaming inspector, so every heat strike surfaces at 3 AM. */
  auditClockMin: 360,

  /** How long a too-drunk admission takes to become somebody's problem. */
  ripenDelayMin: 18,
  ripenVibePerStage: -3,

  /** Stalls buy you time and cost you queue patience, nothing else. */
  stallAggro: 2,
  waitAggro: 3,
  waitHype: 0.2,
};

// NaN-safe on purpose: the caller is meant to pass rng.next(), but a missing or
// junk lapRoll must not poison atClockMin into NaN and silently unschedule the
// lap. An unrolled lap is the earliest lap, not no lap at all.
const clamp01 = (v: number): number => (Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0);

/**
 * Drunk enough that refusing entry is defensible rather than arbitrary. 'loose'
 * is deliberately NOT included — a bit merry is a judgement call, and the game
 * does not get to tell you it was the right one.
 */
function refusableDrunk(p: Patron): boolean {
  const stage = drunkStage(p.intoxication);
  return stage === 'messy' || stage === 'maggot';
}

/** Severity 3 is the ejection-worthy tier in data/contraband.ts. */
function hasSeriousContraband(p: Patron): boolean {
  return (p.flags.contraband ?? []).some(
    (id) => CONTRABAND.find((c) => c.id === id)?.severity === 3,
  );
}

export function judge(patron: Patron, verdict: Verdict, ctx: JudgeContext): DoorOutcome {
  // Stalls are theatre, not rulings. The queue notices the delay; nothing else
  // moves until the player actually says admit or deny, which comes back
  // through here as a normal verdict.
  if (verdict === 'sobrietyTest' || verdict === 'patDown') {
    return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.stallAggro };
  }
  if (verdict === 'wait') {
    return { vibeDelta: 0, aggroDelta: JUDGE_TUNING.waitAggro, hypeDelta: JUDGE_TUNING.waitHype };
  }

  const broken = violations(patron, ctx.clockMin);
  const id = idVerdict(patron.idCard, ctx.nightDate);

  // Dispatch on 'admit' explicitly rather than letting it be the else-branch of
  // 'deny'. Verdict is a closed union today, but if the contract ever grows a
  // verb (bribe, guest-list override) an else-branch would silently score it as
  // an ADMIT — filing heat strikes and capacity breaches for a call the player
  // never made. Falling through to deny is inert by comparison.
  return verdict === 'admit' ? judgeAdmit(patron, broken, id, ctx) : judgeDeny(patron, broken, id);
}

function judgeDeny(patron: Patron, broken: DoorDressCodeRule[], id: IdVerdict): DoorOutcome {
  const T = JUDGE_TUNING;

  // Early return on purpose. Whatever else was wrong with him, Dazza only ever
  // hears that you knocked back the owner's mate, and he hears it tonight —
  // this is the one consequence that does not wait for a lap.
  if (patron.flags.knowsOwner === true) {
    return {
      vibeDelta: T.denyOwnersMateVibe,
      aggroDelta: T.denyOwnersMateAggro,
      dazzaText: 'WHO did u just knock back that bloke plays golf with the owner im getting calls RIGHT NOW mate',
    };
  }

  // "Genuinely violates something": the dress code, a card that reads under 18
  // or fake, an actual minor regardless of what the card claimed — or a state
  // you are not allowed to serve.
  //
  // Intoxication belongs here. Until it did, denying someone who could not
  // stand up was scored identically to denying a clean punter (+7 aggro), so
  // the most legible signal in the game — sway, red eyes, the whole sobriety
  // minigame — was worth nothing, and waving maggots through was the optimal
  // line. RSA says you legally cannot serve them; the scoring now agrees.
  const tooDrunkToServe = refusableDrunk(patron);
  const seriousContraband = hasSeriousContraband(patron);
  const justified =
    broken.length > 0 ||
    id.underage ||
    id.looksFake ||
    patron.age < 18 ||
    tooDrunkToServe ||
    seriousContraband;

  let vibeDelta = justified ? T.denyViolatorVibe : T.denyCleanVibe;
  let aggroDelta = justified ? T.denyViolatorAggro : T.denyCleanAggro;
  let note: string | undefined;

  if (patron.archetype === 'influencer') {
    // The room approves loudly; the phone comes out and the queue watches it.
    vibeDelta += T.denyInfluencerVibe;
    aggroDelta += T.denyInfluencerAggro;
  }

  if (patron.archetype === 'regular' && (patron.flags.timesDeniedBefore ?? 0) > 0) {
    vibeDelta += T.denyGrudgedRegularVibe;
    // Patrons carry no gender, so the note cannot say "he".
    note = 'They kept count. This is not the first time you have knocked them back.';
  }

  // Deliberately not a penalty. Turning away a genuinely 18-year-old with a
  // genuinely real ID is the game's conscience test, and the game never tells
  // you which choice was right (§4.3). Do not make this cost anything.
  // The age guard matches the spec's "genuinely 18" literally: the note claims
  // this is their first legal night, so it must not fire on an older patron who
  // happened to roll the archetype.
  if (!justified && patron.archetype === 'fresh18' && patron.age === 18) {
    note = 'Real ID, first legal night out, and you said no. Good story for them one day.';
  }

  // The inspector scores exactly like a punter, on purpose. Give them bespoke
  // numbers and a player can identify them by watching the meters twitch, which
  // deletes the entire mechanic. This is not an oversight — do not "fix" it.

  return note === undefined ? { vibeDelta, aggroDelta } : { vibeDelta, aggroDelta, note };
}

function judgeAdmit(
  patron: Patron,
  broken: DoorDressCodeRule[],
  id: IdVerdict,
  ctx: JudgeContext,
): DoorOutcome {
  const T = JUDGE_TUNING;
  const cardIsBad = id.underage || id.looksFake;
  const deferred: DeferredHit[] = [];

  let vibeDelta = 0;
  const aggroDelta = broken.length > 0 ? T.admitViolatorAggro : T.admitCleanAggro;

  if (broken.length === 0 && !cardIsBad) vibeDelta += T.admitCleanVibe;
  // The queue only sees a fast door, so waving a dud card through feels good in
  // the moment. That is the entire trap; the bill arrives at the audit.
  if (cardIsBad) vibeDelta += T.admitBadIdVibe;

  if (broken.length > 0) {
    const first = broken[0]!;
    const shorts = broken.map((r) => r.short);
    // Rule `short`s are the words printed on the dress-code card, and every one
    // of them is phrased as a prohibition ("NO VISIBLE LOGOS"). So the line has
    // to QUOTE the sign, never claim to see it: "i can see NO VISIBLE LOGOS"
    // reads as the exact opposite of the accusation Dazza is making.
    const lapAt = ctx.clockMin + T.lapDelayMin + Math.round(clamp01(ctx.lapRoll) * T.lapDelaySpread);
    deferred.push({
      // Clamped to the audit, which is also last drinks. A lap rolled at 2:58 AM
      // would otherwise be scheduled past the end of the night and never fire,
      // making every late admit consequence-free — the exploit being that the
      // last five minutes of every night are a free-for-all.
      atClockMin: Math.min(T.auditClockMin, lapAt),
      vibeDelta: Math.max(T.lapVibeCap, T.lapVibePerRule * broken.length),
      dazzaText: lapLine(first.short, clamp01(ctx.lapRoll)),
      reason: `admitted ${patron.id} in breach: ${shorts.join(', ')}`,
      patronId: patron.id,
    });
  }

  // The quiet messy one (design §4.2): fine at the rope, a problem by midnight.
  // Without this, admitting a visibly drunk patron was free at the desk and its
  // only cost lived in the floor sim — deferred, invisible, and unsized against
  // the aggro you save. Now the trade is real on both sides of the rope.
  const stage = drunkStage(patron.intoxication);
  if (stage === 'loose' || stage === 'messy' || stage === 'maggot') {
    const severity = stage === 'loose' ? 1 : stage === 'messy' ? 2 : 3;
    deferred.push({
      atClockMin: Math.min(T.auditClockMin, ctx.clockMin + T.ripenDelayMin * severity),
      vibeDelta: T.ripenVibePerStage * severity,
      dazzaText: ripenLine(stage, clamp01(ctx.lapRoll)),
      reason: `admitted ${patron.id} already ${stage}`,
      patronId: patron.id,
    });
  }

  // One strike, however many ways it was wrong — a licence only gets pulled
  // once, and the audit files card problems and a real minor as one finding.
  // Note the split: the CARD is what the player could see, the age is what
  // actually costs you the venue. A flawless fake protects nobody.
  if (cardIsBad || patron.age < 18) {
    const found: string[] = [];
    if (id.underage) found.push(`card read ${id.claimedAge}`);
    if (id.looksFake) {
      // idCheck folds 'expired' into `tells`, so `looksFake` is true for a
      // perfectly genuine licence that simply lapsed. Calling a 40-year-old's
      // out-of-date licence "fake" in the incident log is just wrong, so the
      // two findings are reported separately.
      const forged = id.tells.filter((t) => t !== 'expired');
      if (forged.length > 0) found.push(`card was dodgy (${forged.join(', ')})`);
      if (id.expired) found.push('card had expired');
    }
    if (patron.age < 18) found.push(`patron was ${patron.age}`);
    const reason = `ID/age breach admitted — ${patron.id}: ${found.join('; ')}`;
    deferred.push({
      atClockMin: T.auditClockMin,
      heatStrike: { reason, deferred: true },
      reason,
      patronId: patron.id,
    });
  }

  if (ctx.insideCount >= ctx.licensed) {
    // The STRIKE reason is deliberately stable: shouldRecordStrike dedupes by
    // reason, and running the room over capacity is ONE offence the inspector
    // writes up, not one per punter (a patron-id in this string meant every
    // admit past 80 filed its own strike and three admits pulled the licence).
    // The incident log keeps the per-patron detail.
    deferred.push({
      atClockMin: T.auditClockMin,
      heatStrike: { reason: 'room over licensed capacity', deferred: true },
      reason: `capacity breach — ${patron.id} admitted at ${ctx.insideCount}/${ctx.licensed}`,
      patronId: patron.id,
    });
  }

  return deferred.length > 0 ? { vibeDelta, aggroDelta, deferred } : { vibeDelta, aggroDelta };
}
