// Owns the fight lifecycle end to end: when one brews, who's in it, whether you
// got between them in time, and what it cost. The scene only draws and narrates.
// fight.ts stays pure; this is the bit that touches the crowd and the bus.

import type { EventBus } from '../../core/EventBus';
import type { RngStream } from '../../core/SeededRNG';
import type { Activity, Agent, CrowdSim } from './crowdSim';
import {
  FIGHT_COOLDOWN_MS,
  fightEligible,
  freshFight,
  isBetween,
  scoreFight,
  stepFight,
} from './fight';
import type { FightOutcome, FightPhase, FightState } from './fight';
import { FIGHT_BROKEN, FIGHT_CAUSE, FIGHT_SWUNG } from '../../data/strings/floor';

// Not spoken dialogue — this lands in the heat log, so it reads as a licensing
// clerk's shorthand rather than as anything Dazza would say out loud.
const HEAT_REASON = 'Fight on the floor';

// You can only square up from somewhere you can actually swing. Anyone mid-stall,
// mid-escort or already pinned is somebody else's problem.
const AVAILABLE: ReadonlySet<Activity> = new Set<Activity>([
  'walking', 'atBar', 'dancing', 'inBooth', 'smoking',
]);

export interface FightDirectorOpts {
  bus: EventBus;
  rng: RngStream;
  crowd: CrowdSim;
}

interface Live {
  state: FightState;
  a: Agent;
  b: Agent;
  cause: string;
}

export interface FightResolution {
  outcome: FightOutcome;
  phase: FightPhase;
  line: string;
}

export class FightDirector {
  private readonly bus: EventBus;
  private readonly rng: RngStream;
  private readonly crowd: CrowdSim;

  private live: Live | null = null;
  private resolution: FightResolution | null = null;
  // Starts full so the first fight of the night doesn't need a warm-up.
  private cooldownMs = FIGHT_COOLDOWN_MS;

  constructor(opts: FightDirectorOpts) {
    this.bus = opts.bus;
    this.rng = opts.rng;
    this.crowd = opts.crowd;
  }

  /** The live fight, or null. At most ONE at a time — two is unreadable in the dark. */
  get active(): FightState | null {
    return this.live?.state ?? null;
  }

  /** Flavour for the current fight, from strings.ts FIGHT_CAUSE. */
  get cause(): string {
    return this.live?.cause ?? '';
  }

  /** The two agents squaring up, or null. */
  pair(): [Agent, Agent] | null {
    return this.live ? [this.live.a, this.live.b] : null;
  }

  /** Step the fight and/or consider starting one. */
  update(playerX: number, playerY: number, deltaMs: number): void {
    const ms = Math.max(0, deltaMs);
    const live = this.live;
    if (live) {
      this.step(live, playerX, playerY, ms);
      return;
    }
    // Only counted while the floor is calm — a long fight doesn't earn you a rest.
    this.cooldownMs += ms;
    if (this.cooldownMs >= FIGHT_COOLDOWN_MS) this.tryStart();
  }

  /** Latest resolution for the scene to narrate, consumed once (returns then clears). */
  takeResolution(): FightResolution | null {
    const r = this.resolution;
    this.resolution = null;
    return r;
  }

  destroy(): void {
    if (this.live) {
      this.release(this.live.a);
      this.release(this.live.b);
    }
    this.live = null;
    this.resolution = null;
  }

  // ---- internals ----

  private step(live: Live, playerX: number, playerY: number, ms: number): void {
    // Chucked out (or otherwise removed) mid-argument: there's no fight to score,
    // and nobody dealt with anything.
    if (live.a.gone || live.b.gone) {
      this.abort(live);
      return;
    }

    const intervening = isBetween(playerX, playerY, live.a.x, live.a.y, live.b.x, live.b.y);
    const next = stepFight(live.state, ms, intervening);
    live.state = next;
    if (next.phase === 'shoving') return;
    this.resolve(live, next);
  }

  private resolve(live: Live, state: FightState): void {
    const outcome = scoreFight(state);
    const broken = state.phase === 'broken';

    this.bus.emit('meters:delta', { vibe: outcome.vibeDelta, aggro: outcome.aggroDelta });
    // Deferred, not on the spot: design §3.2 hedges a missed fight as "possible
    // Heat", and the contract's deferred flag means it surfaces at inspection or
    // audit instead. A brawl you didn't stop is exactly the sort of thing that
    // comes back at you later — that's the game's long-memory conscience mechanic.
    if (outcome.heat) this.bus.emit('heat:strike', { reason: HEAT_REASON, deferred: true });

    const line = this.rng.pick(broken ? FIGHT_BROKEN : FIGHT_SWUNG);
    this.release(live.a);
    this.release(live.b);
    // A fight you missed is not a fight you dealt with — a swing leaves them both
    // still on your list.
    if (broken) {
      live.a.handled = true;
      live.b.handled = true;
    }

    this.resolution = { outcome, phase: state.phase, line };
    this.end();
  }

  private abort(live: Live): void {
    this.release(live.a);
    this.release(live.b);
    this.end();
  }

  private end(): void {
    this.live = null;
    this.cooldownMs = 0;
  }

  /** Never drag someone back onto the floor after they've left it. */
  private release(agent: Agent): void {
    if (agent.gone) return;
    this.crowd.releaseFromFight(agent);
  }

  private tryStart(): void {
    const pairs = this.candidates();
    if (pairs.length === 0) return;

    const [a, b] = this.rng.pick(pairs);
    if (!this.crowd.pinForFight(a)) return;
    if (!this.crowd.pinForFight(b)) {
      this.crowd.releaseFromFight(a);
      return;
    }

    this.live = { state: freshFight(a.patron.id, b.patron.id), a, b, cause: this.rng.pick(FIGHT_CAUSE) };
    this.bus.emit('floor:infractionSpotted', { patronId: a.patron.id, kind: 'fight' });
  }

  /** Every pair drunk enough and close enough right now. Small crowd, so O(n²) is fine. */
  private candidates(): Array<[Agent, Agent]> {
    const pool = this.crowd.agents.filter(
      (a) => !a.gone && !a.handled && AVAILABLE.has(a.activity),
    );
    const out: Array<[Agent, Agent]> = [];
    for (let i = 0; i < pool.length; i++) {
      const a = pool[i];
      if (!a) continue;
      for (let j = i + 1; j < pool.length; j++) {
        const b = pool[j];
        if (!b) continue;
        const dist = Math.hypot(a.x - b.x, a.y - b.y);
        if (fightEligible(a.patron.intoxication, b.patron.intoxication, dist)) out.push([a, b]);
      }
    }
    return out;
  }
}
