// The living crowd. Renderer-agnostic on purpose: this owns where everyone is
// and what they're doing, the Phaser layer just draws whatever it finds here.

import type { EventBus } from '../../core/EventBus';
import type { RngStream } from '../../core/SeededRNG';
import type { Archetype, DrunkStage, Patron } from '../../data/types';
import { drunkStage } from '../../rules/drunk';
import { isWalkable, isWalkableWorld, tileToWorld, worldToTile } from './venueMap';
import type { StallDef, VenueMap, WorldPoint } from './venueMap';

export type Activity =
  | 'walking' | 'atBar' | 'dancing' | 'inBooth' | 'inStall' | 'smoking' | 'escorted' | 'leaving'
  | 'squaringUp';

export interface Agent {
  patron: Patron;
  x: number; y: number;
  vx: number; vy: number;
  activity: Activity;
  target: WorldPoint;
  dwellMs: number;          // ms left at the current anchor
  bobPhase: number;         // 0..1, advanced on beat:tick, drives the dance bob
  stallId?: string;         // set while activity === 'inStall'
  handled: boolean;         // player has already dealt with this one
  gone: boolean;            // reached the exit / left the sim; scene should drop the sprite
  lastBumpMs: number;       // cooldown so bumping can't spam meters
  /** In-game minutes this patron intends to stay before going home. */
  stayMinutes: number;
  /** Clock minute they hit the floor (set on first update tick). */
  arrivedAtMin?: number;
  /** Done for the night: walking to the entry, then gone (emits floor:leave). */
  headingHome?: boolean;
}

export interface CrowdSimOpts {
  map: VenueMap;
  bus: EventBus;
  rng: RngStream;
}

type Haunt = 'bar' | 'dance' | 'booth' | 'toilet' | 'smoke';

const HAUNT_KINDS: readonly Haunt[] = ['bar', 'dance', 'booth', 'toilet', 'smoke'];

// Where each sort of punter gravitates. Weights, not probabilities — every
// archetype needs an entry or the crowd stops moving.
const HAUNTS: Readonly<Record<Archetype, Readonly<Record<Haunt, number>>>> = {
  fresh18: { bar: 3, dance: 6, booth: 2, toilet: 2, smoke: 1 },
  almost18: { bar: 2, dance: 5, booth: 3, toilet: 2, smoke: 2 },
  financeBro: { bar: 8, dance: 2, booth: 3, toilet: 2, smoke: 1 },
  influencer: { bar: 2, dance: 8, booth: 2, toilet: 2, smoke: 1 },
  regular: { bar: 6, dance: 2, booth: 3, toilet: 1, smoke: 4 },
  ownersMate: { bar: 7, dance: 2, booth: 5, toilet: 1, smoke: 2 },
  quietMessy: { bar: 3, dance: 1, booth: 7, toilet: 3, smoke: 2 },
  inspector: { bar: 2, dance: 1, booth: 4, toilet: 2, smoke: 1 },
  punter: { bar: 4, dance: 4, booth: 3, toilet: 2, smoke: 2 },
};

const ACTIVITY_FOR: Readonly<Record<Haunt, Activity>> = {
  bar: 'atBar',
  dance: 'dancing',
  booth: 'inBooth',
  toilet: 'inStall',
  smoke: 'smoking',
};

const SPEED_MIN = 28;
const SPEED_MAX = 44;
// Transits across a 1280x720 venue take ~30s at these speeds, so short dwells
// left ~68% of the crowd permanently walking and the bar/dance floor reading as
// empty. The player has to be able to FIND infractions where they'd expect them.
const DWELL_MIN_MS = 7000;
const DWELL_MAX_MS = 22_000;
const ARRIVE_PX = 2.5;
const WAYPOINT_PX = 9;      // half a tile — loose enough that a drunk sway can't orbit one
const MAX_STEP_MS = 50;
const STUCK_MS = 700;

// Intoxication per in-game minute for a patron with zero tolerance. At the bar
// you're actively buying; everywhere else you're nursing what you've got.
const BAR_DRIFT_PER_MIN = 0.011;
const AMBIENT_DRIFT_PER_MIN = 0.0012;

const SWAY_BY_STAGE: Readonly<Record<DrunkStage, number>> = {
  sober: 0, tipsy: 4, loose: 10, messy: 18, maggot: 26,
};
const SWAY_RATE = 0.006;          // radians per ms — roughly a one-second wobble
const STUMBLE_CHANCE_PER_SEC = 0.5;
const STUMBLE_MIN_MS = 180;
const STUMBLE_MAX_MS = 520;
const STUMBLE_ARC = 0.9;          // worst heading error, radians

const BUMP_DIST_PX = 10;
const BUMP_COOLDOWN_MS = 12_000;
// Per-agent cooldown alone doesn't bound the LEAK — the emission rate scales
// with how many rowdy patrons are on the floor, so a full late-night venue
// pinned vibe to 0 and aggro to 100 by ~1AM with the player doing nothing.
// This gate makes ambient chaos cost a roughly fixed amount per night
// regardless of crowd size; the player's own mistakes stay legible against it.
const BUMP_GATE_MS = 8000;
const BUMP_VIBE = -0.4;
const BUMP_AGGRO = 0.6;

// Chance a dunny-bound patron shoulders into a cubicle that's already busy.
const STALL_SHARE_CHANCE = 0.3;
const STALL_CAP = 2;

const ESCORT_SPEED = 22;          // the walk of a bloke who knows it's over
const ESCORT_AHEAD_PX = 18;       // they only shuffle on while you're this close behind
const BOB_STEP = 0.25;            // four beats to a full bob, i.e. one bar

const NEIGHBOURS = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const;

interface Plan {
  haunt: Haunt;
  stallId?: string;      // reserved while walking, so the cap survives the trip over
  path: WorldPoint[];    // detour waypoints; empty means steer straight at the target
  bestDist: number;      // closest approach to the current steer point so far
  stuckMs: number;
  stumbleMs: number;
  stumbleAngle: number;
}

function rowdy(a: Agent): boolean {
  const s = drunkStage(a.patron.intoxication);
  return s === 'messy' || s === 'maggot';
}

/** Deterministic per-patron pace — same doll, same dawdle, every run. */
function paceOf(patron: Patron): number {
  const span = SPEED_MAX - SPEED_MIN + 1;
  return SPEED_MIN + (Math.abs(patron.dollSeed) % span);
}

export class CrowdSim {
  readonly agents: Agent[] = [];

  private readonly map: VenueMap;
  private readonly bus: EventBus;
  private readonly rng: RngStream;
  private readonly plans = new WeakMap<Agent, Plan>();
  private elapsedMs = 0;
  private prevClockMin = Number.NaN;
  private bumpGateMs = BUMP_GATE_MS;

  constructor(opts: CrowdSimOpts) {
    this.map = opts.map;
    this.bus = opts.bus;
    this.rng = opts.rng;
  }

  spawn(patron: Patron): Agent {
    const entries = this.map.anchors.entry;
    const at = entries.length > 0 ? this.rng.pick(entries) : { x: 0, y: 0 };
    const agent: Agent = {
      patron,
      x: at.x,
      y: at.y,
      vx: 0,
      vy: 0,
      activity: 'walking',
      target: { x: at.x, y: at.y },
      dwellMs: 0,
      bobPhase: (Math.abs(patron.dollSeed) % 100) / 100,
      handled: false,
      gone: false,
      lastBumpMs: BUMP_COOLDOWN_MS,
      // Nobody stays all night. 50-140 in-game minutes, then they head home —
      // without this, capacity was a one-way ratchet and the door had to lock
      // shut from 1 AM (tuning pass 2026-07-19). Derived from dollSeed, not the
      // stream: drawing here would shift every later roll in the sim.
      stayMinutes: 50 + (Math.abs(patron.dollSeed) % 91),
    };
    this.plans.set(agent, {
      haunt: 'bar', path: [], bestDist: Infinity, stuckMs: 0, stumbleMs: 0, stumbleAngle: 0,
    });
    this.agents.push(agent);
    this.chooseTarget(agent);
    return agent;
  }

  /** deltaMs of real time; clockMin is the in-game minute (drives intoxication drift). */
  update(deltaMs: number, clockMin: number): void {
    const ms = Math.max(0, Math.min(deltaMs, MAX_STEP_MS));
    this.elapsedMs += ms;
    this.bumpGateMs += ms;

    this.drift(clockMin);

    for (const a of this.agents) {
      if (a.gone) continue;
      a.lastBumpMs += ms;
      if (a.arrivedAtMin === undefined) a.arrivedAtMin = clockMin;
      // Both are driven from outside: the player's escort, and the fight director.
      if (a.activity === 'escorted' || a.activity === 'squaringUp') continue;
      if (
        !a.headingHome &&
        a.activity !== 'inStall' &&
        clockMin >= a.arrivedAtMin + a.stayMinutes
      ) {
        this.headHome(a);
      }
      if (a.activity === 'walking') this.walk(a, ms);
      else this.dwell(a, ms);
    }

    this.bumps();
  }

  /** Call from a bus.on('beat:tick') handler. Advances dance bob only. */
  onBeat(beatIndex: number): void {
    // The downbeat lands harder than the rest of the bar.
    const step = beatIndex % 4 === 0 ? BOB_STEP * 1.5 : BOB_STEP;
    for (const a of this.agents) {
      if (a.gone || a.activity !== 'dancing') continue;
      a.bobPhase = (a.bobPhase + step) % 1;
    }
  }

  /** Agents currently inside each stall, keyed by stall id. */
  stallOccupancy(): Map<string, Agent[]> {
    const out = new Map<string, Agent[]>();
    for (const s of this.map.stalls) out.set(s.id, []);
    for (const a of this.agents) {
      if (a.gone || a.activity !== 'inStall' || a.stallId === undefined) continue;
      const list = out.get(a.stallId);
      if (list) list.push(a);
      else out.set(a.stallId, [a]);
    }
    return out;
  }

  /** Attach to the player for a walk-to-the-exit escort. Returns false if already escorted. */
  beginEscort(agent: Agent): boolean {
    if (agent.gone || agent.activity === 'escorted' || agent.activity === 'leaving') return false;
    const exit = this.nearestExit(agent.x, agent.y);
    if (!exit) return false;

    if (agent.activity === 'inStall') {
      const stall = this.stallById(agent.stallId);
      if (stall) {
        agent.x = stall.door.x;
        agent.y = stall.door.y;
      }
    }
    agent.stallId = undefined;
    agent.activity = 'escorted';
    agent.handled = true;
    agent.dwellMs = 0;
    agent.target = exit;

    const plan = this.plans.get(agent);
    if (plan) {
      plan.stallId = undefined;
      plan.stumbleMs = 0;
      plan.bestDist = Infinity;
      plan.stuckMs = 0;
      // Always route the perp walk — the door is a long way from the dunnies and
      // a straight line would wedge them in a corner with you shouting at them.
      plan.path = this.findPath(agent.x, agent.y, exit) ?? [];
    }
    return true;
  }

  /** Called each frame with the player position so escorted agents walk ahead of you. */
  updateEscorts(playerX: number, playerY: number, deltaMs: number): void {
    const dt = Math.max(0, Math.min(deltaMs, MAX_STEP_MS)) / 1000;
    const step = ESCORT_SPEED * dt;

    for (const a of this.agents) {
      if (a.gone || a.activity !== 'escorted') continue;
      const plan = this.plans.get(a);
      if (!plan) continue;

      const point = this.steerPoint(a, plan);
      const dx = point.x - a.x;
      const dy = point.y - a.y;
      const dist = Math.hypot(dx, dy);

      if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
        a.x = point.x;
        a.y = point.y;
        a.vx = 0;
        a.vy = 0;
        a.activity = 'leaving';
        a.gone = true;
        continue;
      }

      // They shuffle on only while you're breathing down their neck; stop pushing
      // and they stop walking.
      if (Math.hypot(playerX - a.x, playerY - a.y) > ESCORT_AHEAD_PX) {
        a.vx = 0;
        a.vy = 0;
        continue;
      }

      const ux = dx / (dist || 1);
      const uy = dy / (dist || 1);
      a.vx = ux * ESCORT_SPEED;
      a.vy = uy * ESCORT_SPEED;
      this.tryMove(a, ux * step, uy * step);
    }
  }

  /** Pin an agent for a brewing fight. False if it isn't available (gone/escorted/in a stall). */
  pinForFight(agent: Agent): boolean {
    if (agent.gone) return false;
    if (
      agent.activity === 'escorted' || agent.activity === 'leaving' ||
      agent.activity === 'inStall' || agent.activity === 'squaringUp'
    ) return false;

    agent.activity = 'squaringUp';
    agent.vx = 0;
    agent.vy = 0;
    // Whatever they were nursing is over; the fight owns the dwell now.
    agent.dwellMs = 0;
    return true;
  }

  /** Release back into the crowd; they walk it off. */
  releaseFromFight(agent: Agent): void {
    // Anything else got hold of them mid-fight (escorted, then gone) — leave it alone.
    if (agent.activity !== 'squaringUp') return;
    agent.activity = 'walking';
    agent.dwellMs = 0;
    agent.stallId = undefined;
    const plan = this.plans.get(agent);
    if (plan) plan.stumbleMs = 0;
    this.chooseTarget(agent);
  }

  find(patronId: string): Agent | undefined {
    return this.agents.find((a) => a.patron.id === patronId);
  }

  destroy(): void {
    // Nothing to unsubscribe — beat ticks are pushed in by the owning scene.
    this.agents.length = 0;
  }

  // ---- internals ----

  private drift(clockMin: number): void {
    const prev = this.prevClockMin;
    this.prevClockMin = clockMin;
    if (Number.isNaN(prev)) return;
    const mins = clockMin - prev;
    if (mins <= 0) return;

    for (const a of this.agents) {
      if (a.gone) continue;
      const base = a.activity === 'atBar' ? BAR_DRIFT_PER_MIN : AMBIENT_DRIFT_PER_MIN;
      a.patron.intoxication = Math.min(1, a.patron.intoxication + base * (1 - a.patron.tolerance) * mins);
    }
  }

  private dwell(a: Agent, ms: number): void {
    a.vx = 0;
    a.vy = 0;
    a.dwellMs -= ms;
    if (a.dwellMs > 0) return;

    if (a.activity === 'inStall') {
      const stall = this.stallById(a.stallId);
      if (stall) {
        a.x = stall.door.x;
        a.y = stall.door.y;
      }
      a.stallId = undefined;
    }
    a.dwellMs = 0;
    a.activity = 'walking';
    this.chooseTarget(a);
  }

  private walk(a: Agent, ms: number): void {
    const plan = this.plans.get(a);
    if (!plan) return;

    const dt = ms / 1000;
    const speed = paceOf(a.patron);
    const step = speed * dt;
    const point = this.steerPoint(a, plan);
    const dx = point.x - a.x;
    const dy = point.y - a.y;
    const dist = Math.hypot(dx, dy);

    if (plan.path.length === 0 && dist <= Math.max(ARRIVE_PX, step)) {
      this.arrive(a, plan);
      return;
    }

    // Walked into geometry the straight line can't solve (the dunny room is one
    // long concave corner). Fall back to a real route rather than pushing at a wall.
    if (dist < plan.bestDist - 0.5) {
      plan.bestDist = dist;
      plan.stuckMs = 0;
    } else {
      plan.stuckMs += ms;
      if (plan.stuckMs >= STUCK_MS) this.unstick(a, plan);
    }

    let heading = Math.atan2(dy, dx);
    const stage = drunkStage(a.patron.intoxication);

    if (plan.stumbleMs > 0) {
      plan.stumbleMs -= ms;
      heading += plan.stumbleAngle;
    } else if ((stage === 'messy' || stage === 'maggot') && this.rng.chance(STUMBLE_CHANCE_PER_SEC * dt)) {
      plan.stumbleMs = this.rng.int(STUMBLE_MIN_MS, STUMBLE_MAX_MS);
      plan.stumbleAngle = (this.rng.next() * 2 - 1) * STUMBLE_ARC;
      heading += plan.stumbleAngle;
    }

    const ux = Math.cos(heading);
    const uy = Math.sin(heading);
    // Sway rides perpendicular to travel — the wider the wobble, the drunker the walk.
    const sway = Math.sin(this.elapsedMs * SWAY_RATE + a.patron.dollSeed) * SWAY_BY_STAGE[stage];
    a.vx = ux * speed - uy * sway;
    a.vy = uy * speed + ux * sway;
    this.tryMove(a, a.vx * dt, a.vy * dt);
  }

  /** Current thing to walk at: the next detour waypoint, else the target itself. */
  private steerPoint(a: Agent, plan: Plan): WorldPoint {
    while (plan.path.length > 0) {
      const wp = plan.path[0];
      if (!wp) {
        plan.path.shift();
        continue;
      }
      if (Math.hypot(wp.x - a.x, wp.y - a.y) > WAYPOINT_PX) return wp;
      plan.path.shift();
      plan.bestDist = Infinity;
      plan.stuckMs = 0;
    }
    return a.target;
  }

  private unstick(a: Agent, plan: Plan): void {
    plan.stuckMs = 0;
    plan.bestDist = Infinity;
    const path = this.findPath(a.x, a.y, a.target);
    if (path && path.length > 0) plan.path = path;
    else this.chooseTarget(a); // nowhere to go from here — find somewhere else to be
  }

  /** Done for the night: point them at the way out. */
  private headHome(a: Agent): void {
    const plan = this.plans.get(a);
    if (!plan) return;
    a.headingHome = true;
    a.activity = 'walking';
    a.dwellMs = 0;
    plan.path = [];
    plan.bestDist = Infinity;
    plan.stuckMs = 0;
    plan.stallId = undefined;
    const entries = this.map.anchors.entry;
    if (entries.length > 0) a.target = this.rng.pick(entries);
  }

  private arrive(a: Agent, plan: Plan): void {
    a.vx = 0;
    a.vy = 0;

    if (a.headingHome) {
      a.activity = 'leaving';
      a.gone = true;
      this.bus.emit('floor:leave', { patronId: a.patron.id });
      return;
    }

    a.dwellMs = this.rng.int(DWELL_MIN_MS, DWELL_MAX_MS);

    if (plan.haunt === 'toilet' && plan.stallId !== undefined) {
      const stall = this.stallById(plan.stallId);
      if (stall) {
        a.activity = 'inStall';
        a.stallId = stall.id;
        a.x = stall.inside.x;
        a.y = stall.inside.y;
        return;
      }
    }
    a.x = a.target.x;
    a.y = a.target.y;
    a.activity = ACTIVITY_FOR[plan.haunt];
  }

  private chooseTarget(a: Agent): void {
    const plan = this.plans.get(a);
    if (!plan) return;
    plan.stallId = undefined;
    plan.path = [];
    plan.bestDist = Infinity;
    plan.stuckMs = 0;

    const weights = HAUNTS[a.patron.archetype];
    let haunt = this.rng.weighted(HAUNT_KINDS.map((k) => [k, weights[k]] as const));

    if (haunt === 'toilet') {
      const stall = this.claimStall();
      if (stall) {
        plan.haunt = 'toilet';
        plan.stallId = stall.id;
        a.target = stall.door;
        return;
      }
      haunt = 'bar'; // every dunny's full — go queue for another drink instead
    }

    const spots = this.map.anchors[haunt];
    plan.haunt = haunt;
    if (spots.length > 0) a.target = this.rng.pick(spots);
  }

  /** Reserve a cubicle, occasionally squeezing into one that's already busy. */
  private claimStall(): StallDef | undefined {
    const counts = new Map<string, number>();
    for (const s of this.map.stalls) counts.set(s.id, 0);
    const bump = (id: string | undefined): void => {
      if (id === undefined) return;
      counts.set(id, (counts.get(id) ?? 0) + 1);
    };
    for (const other of this.agents) {
      if (other.gone) continue;
      bump(other.stallId);
      const p = this.plans.get(other);
      if (other.activity === 'walking' && p) bump(p.stallId);
    }

    const free = this.map.stalls.filter((s) => (counts.get(s.id) ?? 0) === 0);
    const busy = this.map.stalls.filter((s) => {
      const n = counts.get(s.id) ?? 0;
      return n > 0 && n < STALL_CAP;
    });

    if (busy.length > 0 && (free.length === 0 || this.rng.chance(STALL_SHARE_CHANCE))) {
      return this.rng.pick(busy);
    }
    if (free.length > 0) return this.rng.pick(free);
    return undefined;
  }

  private bumps(): void {
    for (let i = 0; i < this.agents.length; i++) {
      const a = this.agents[i];
      if (!a || a.gone) continue;
      for (let j = i + 1; j < this.agents.length; j++) {
        const b = this.agents[j];
        if (!b || b.gone) continue;
        if (Math.hypot(a.x - b.x, a.y - b.y) >= BUMP_DIST_PX) continue;
        for (const who of [a, b]) {
          if (!rowdy(who) || who.lastBumpMs < BUMP_COOLDOWN_MS) continue;
          who.lastBumpMs = 0;
          // Still track the individual cooldown above (so one pair grinding
          // together can't chain), but only the gate opens the till.
          if (this.bumpGateMs < BUMP_GATE_MS) continue;
          this.bumpGateMs = 0;
          this.bus.emit('meters:delta', { vibe: BUMP_VIBE, aggro: BUMP_AGGRO });
        }
      }
    }
  }

  private tryMove(a: Agent, dx: number, dy: number): void {
    if (this.open(a.x + dx, a.y + dy)) {
      a.x += dx;
      a.y += dy;
      return;
    }
    if (this.open(a.x + dx, a.y)) a.x += dx;       // slide along the wall
    else if (this.open(a.x, a.y + dy)) a.y += dy;
  }

  private open(x: number, y: number): boolean {
    return isWalkableWorld(this.map, x, y);
  }

  /** 4-connected BFS over walkable tiles. Waypoints are tile centres. */
  private findPath(fromX: number, fromY: number, to: WorldPoint): WorldPoint[] | undefined {
    const w = this.map.width;
    const h = this.map.height;
    const start = worldToTile(fromX, fromY);
    const goal = worldToTile(to.x, to.y);
    const startK = start.ty * w + start.tx;
    const goalK = goal.ty * w + goal.tx;
    if (startK === goalK) return [];
    if (!isWalkable(this.map, goal.tx, goal.ty)) return undefined;

    const prev = new Int32Array(w * h).fill(-1);
    const seen = new Uint8Array(w * h);
    const queue = [startK];
    seen[startK] = 1;
    for (let head = 0; head < queue.length; head++) {
      const cur = queue[head] ?? -1;
      if (cur === goalK) break;
      const cx = cur % w;
      const cy = (cur - cx) / w;
      for (const [dx, dy] of NEIGHBOURS) {
        const nx = cx + dx;
        const ny = cy + dy;
        if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;
        const k = ny * w + nx;
        if (seen[k] === 1 || !isWalkable(this.map, nx, ny)) continue;
        seen[k] = 1;
        prev[k] = cur;
        queue.push(k);
      }
    }
    if (seen[goalK] !== 1) return undefined;

    const out: WorldPoint[] = [];
    let cur = goalK;
    while (cur !== startK) {
      const cx = cur % w;
      out.push(tileToWorld(cx, (cur - cx) / w));
      const p = prev[cur] ?? -1;
      if (p < 0) break;
      cur = p;
    }
    out.reverse();
    return out;
  }

  private stallById(id: string | undefined): StallDef | undefined {
    if (id === undefined) return undefined;
    return this.map.stalls.find((s) => s.id === id);
  }

  private nearestExit(x: number, y: number): WorldPoint | undefined {
    let best: WorldPoint | undefined;
    let bestD = Infinity;
    for (const e of this.map.anchors.exit) {
      const d = Math.hypot(e.x - x, e.y - y);
      if (d < bestD) {
        bestD = d;
        best = e;
      }
    }
    return best;
  }
}
