import { describe, expect, it } from 'vitest';
import {
  BREAK_RADIUS_PX,
  FIGHT_FUSE_MS,
  FIGHT_MIN_DIST_PX,
  FIGHT_MIN_INTOX,
  fightEligible,
  freshFight,
  isBetween,
  scoreFight,
  shoveLine,
  stepFight,
  type FightState,
} from '../../src/scenes/floor/fight';
import { FIGHT_SHOVES } from '../../src/data/strings/floor';
import { drunkStage } from '../../src/rules/drunk';

/** A 200px-apart pair on a horizontal line — long enough to have real shoulders. */
const A = { x: 100, y: 100 };
const B = { x: 300, y: 100 };
const MID = { x: 200, y: 100 };

describe('freshFight', () => {
  it('starts shoving with a full fuse and no escalation', () => {
    const f = freshFight('a', 'b');
    expect(f.phase).toBe('shoving');
    expect(f.msLeft).toBe(FIGHT_FUSE_MS);
    expect(f.shoves).toBe(0);
    expect(f.aId).toBe('a');
    expect(f.bId).toBe('b');
  });
});

describe('isBetween', () => {
  it('is true dead centre', () => {
    expect(isBetween(MID.x, MID.y, A.x, A.y, B.x, B.y)).toBe(true);
  });

  it('is true anywhere along the segment', () => {
    for (let x = A.x; x <= B.x; x += 10) {
      expect(isBetween(x, A.y, A.x, A.y, B.x, B.y)).toBe(true);
    }
  });

  it('is FALSE past A on the extended line — the segment, not the infinite line', () => {
    const past = A.x - BREAK_RADIUS_PX - 1;
    expect(isBetween(past, A.y, A.x, A.y, B.x, B.y)).toBe(false);
  });

  it('is FALSE past B on the extended line', () => {
    const past = B.x + BREAK_RADIUS_PX + 1;
    expect(isBetween(past, B.y, A.x, A.y, B.x, B.y)).toBe(false);
  });

  it('is false a long way past either shoulder', () => {
    expect(isBetween(-5000, A.y, A.x, A.y, B.x, B.y)).toBe(false);
    expect(isBetween(5000, A.y, A.x, A.y, B.x, B.y)).toBe(false);
  });

  it('is false perpendicular beyond the radius', () => {
    expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX + 1, A.x, A.y, B.x, B.y)).toBe(false);
  });

  it('honours the radius at its exact boundary', () => {
    expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX, A.x, A.y, B.x, B.y)).toBe(true);
    expect(isBetween(MID.x, MID.y + BREAK_RADIUS_PX + 0.001, A.x, A.y, B.x, B.y)).toBe(false);
  });

  it('honours an explicit radius override at its boundary', () => {
    expect(isBetween(MID.x, MID.y + 10, A.x, A.y, B.x, B.y, 10)).toBe(true);
    expect(isBetween(MID.x, MID.y + 10.001, A.x, A.y, B.x, B.y, 10)).toBe(false);
  });

  it('measures the shoulder radially, not just along the axis', () => {
    // Diagonally off A's corner, just outside the radius.
    const d = BREAK_RADIUS_PX;
    expect(isBetween(A.x - d, A.y - d, A.x, A.y, B.x, B.y)).toBe(false);
    expect(isBetween(A.x - 1, A.y - 1, A.x, A.y, B.x, B.y)).toBe(true);
  });

  it('does not throw or NaN when A and B are the same point', () => {
    expect(isBetween(A.x, A.y, A.x, A.y, A.x, A.y)).toBe(true);
    expect(isBetween(A.x + BREAK_RADIUS_PX, A.y, A.x, A.y, A.x, A.y)).toBe(true);
    expect(isBetween(A.x + BREAK_RADIUS_PX + 1, A.y, A.x, A.y, A.x, A.y)).toBe(false);
  });

  it('works on a vertical pair too', () => {
    expect(isBetween(100, 200, 100, 100, 100, 300)).toBe(true);
    expect(isBetween(100, 300 + BREAK_RADIUS_PX + 1, 100, 100, 100, 300)).toBe(false);
  });
});

describe('stepFight purity', () => {
  it('never mutates the input state', () => {
    const f = freshFight('a', 'b');
    const snapshot = { ...f };
    stepFight(f, 3000, false);
    stepFight(f, 3000, true);
    expect(f).toEqual(snapshot);
  });

  it('returns a new object while shoving', () => {
    const f = freshFight('a', 'b');
    expect(stepFight(f, 100, false)).not.toBe(f);
  });
});

describe('stepFight fuse', () => {
  it('burns msLeft down by the delta', () => {
    const f = stepFight(freshFight('a', 'b'), 1000, false);
    expect(f.msLeft).toBe(FIGHT_FUSE_MS - 1000);
    expect(f.phase).toBe('shoving');
  });

  it('is still shoving one millisecond before the fuse ends', () => {
    const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS - 1, false);
    expect(f.phase).toBe('shoving');
    expect(f.msLeft).toBe(1);
  });

  it('swings at exactly zero', () => {
    const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
    expect(f.phase).toBe('swung');
    expect(f.msLeft).toBe(0);
  });

  it('lands on swung — not past it — for one huge delta', () => {
    const f = stepFight(freshFight('a', 'b'), 10_000_000, false);
    expect(f.phase).toBe('swung');
    expect(f.msLeft).toBe(0);
    expect(f.shoves).toBe(5);
  });

  it('reaches the same place in many small steps as in one big one', () => {
    let f = freshFight('a', 'b');
    for (let i = 0; i < 100; i++) f = stepFight(f, 100, false);
    expect(f.phase).toBe('swung');
    expect(f.msLeft).toBe(0);
  });
});

describe('stepFight intervention', () => {
  it('breaks immediately when you get between them', () => {
    const f = stepFight(freshFight('a', 'b'), 16, true);
    expect(f.phase).toBe('broken');
  });

  it('freezes msLeft where it was when broken', () => {
    const live = stepFight(freshFight('a', 'b'), 2000, false);
    const broken = stepFight(live, 500, true);
    expect(broken.msLeft).toBe(live.msLeft);
  });

  it('beats the fuse when both land on the same step', () => {
    const f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS * 2, true);
    expect(f.phase).toBe('broken');
  });
});

describe('stepFight terminal phases absorb', () => {
  it('a swung fight can never become broken', () => {
    let f = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
    expect(f.phase).toBe('swung');
    for (let i = 0; i < 10; i++) f = stepFight(f, 1000, true);
    expect(f.phase).toBe('swung');
    expect(f.msLeft).toBe(0);
  });

  it('a broken fight can never become swung', () => {
    let f = stepFight(freshFight('a', 'b'), 100, true);
    expect(f.phase).toBe('broken');
    const frozen = f.msLeft;
    for (let i = 0; i < 10; i++) f = stepFight(f, 100_000, false);
    expect(f.phase).toBe('broken');
    expect(f.msLeft).toBe(frozen);
  });

  it('returns the identical object once terminal', () => {
    const swung = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS, false);
    expect(stepFight(swung, 50, false)).toBe(swung);
    const broken = stepFight(freshFight('a', 'b'), 50, true);
    expect(stepFight(broken, 50, true)).toBe(broken);
  });
});

describe('stepFight shoves counter', () => {
  it('is monotonic and capped at 5 across the whole fuse', () => {
    let f = freshFight('a', 'b');
    let prev = f.shoves;
    for (let i = 0; i < 200; i++) {
      f = stepFight(f, 50, false);
      expect(f.shoves).toBeGreaterThanOrEqual(prev);
      expect(f.shoves).toBeLessThanOrEqual(5);
      expect(f.shoves).toBeGreaterThanOrEqual(0);
      prev = f.shoves;
    }
    expect(f.phase).toBe('swung');
    expect(f.shoves).toBe(5);
  });

  it('ticks one rung per fifth of the fuse', () => {
    const fifth = FIGHT_FUSE_MS / 5;
    for (let rung = 0; rung <= 4; rung++) {
      const f = stepFight(freshFight('a', 'b'), fifth * rung, false);
      expect(f.shoves).toBe(rung);
    }
  });

  it('does not tick before the first fifth has burned', () => {
    expect(stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS / 5 - 1, false).shoves).toBe(0);
  });

  it('holds its rung when the fight is broken', () => {
    const live = stepFight(freshFight('a', 'b'), FIGHT_FUSE_MS * 0.6, false);
    expect(live.shoves).toBe(3);
    expect(stepFight(live, 5000, true).shoves).toBe(3);
  });
});

describe('shoveLine', () => {
  it('gives a real line for every reachable rung, including the capped one', () => {
    for (let s = 0; s <= 5; s++) {
      expect(shoveLine(s).length).toBeGreaterThan(0);
      expect(FIGHT_SHOVES).toContain(shoveLine(s));
    }
  });

  it('clamps rubbish input rather than returning undefined', () => {
    expect(shoveLine(-3)).toBe(FIGHT_SHOVES[0]);
    expect(shoveLine(999)).toBe(FIGHT_SHOVES[FIGHT_SHOVES.length - 1]);
  });
});

describe('scoreFight', () => {
  const at = (phase: FightState['phase']): FightState => ({
    aId: 'a', bId: 'b', msLeft: 1000, phase, shoves: 2,
  });

  it('rewards a broken fight with vibe and no heat', () => {
    const o = scoreFight(at('broken'));
    expect(o.vibeDelta).toBeGreaterThan(0);
    expect(o.heat).toBe(false);
    expect(o.aggroDelta).toBeGreaterThan(0);
  });

  it('punishes a landed swing hard, with heat', () => {
    const o = scoreFight(at('swung'));
    expect(o.vibeDelta).toBeLessThan(0);
    expect(o.heat).toBe(true);
    expect(o.aggroDelta).toBeGreaterThan(0);
  });

  it('costs more vibe to miss than it pays to break up', () => {
    expect(Math.abs(scoreFight(at('swung')).vibeDelta))
      .toBeGreaterThan(scoreFight(at('broken')).vibeDelta);
    expect(scoreFight(at('swung')).aggroDelta)
      .toBeGreaterThan(scoreFight(at('broken')).aggroDelta);
  });

  it('scores a live fight at zero', () => {
    const o = scoreFight(at('shoving'));
    expect(o).toEqual({ vibeDelta: 0, aggroDelta: 0, heat: false });
  });

  it('only ever raises heat on a swing', () => {
    expect(scoreFight(at('shoving')).heat).toBe(false);
    expect(scoreFight(at('broken')).heat).toBe(false);
    expect(scoreFight(at('swung')).heat).toBe(true);
  });
});

describe('fightEligible', () => {
  it('is false for a sober pair standing on each other', () => {
    expect(fightEligible(0, 0, 1)).toBe(false);
  });

  it('is false when only one of them is drunk', () => {
    expect(fightEligible(0.9, 0.1, 10)).toBe(false);
    expect(fightEligible(0.1, 0.9, 10)).toBe(false);
  });

  it('is false for a drunk pair on opposite sides of the room', () => {
    expect(fightEligible(0.9, 0.9, FIGHT_MIN_DIST_PX + 1)).toBe(false);
    expect(fightEligible(0.9, 0.9, 400)).toBe(false);
  });

  it('is true for a close, drunk pair', () => {
    expect(fightEligible(0.7, 0.6, 20)).toBe(true);
  });

  it('is true at both boundaries exactly', () => {
    expect(fightEligible(FIGHT_MIN_INTOX, FIGHT_MIN_INTOX, FIGHT_MIN_DIST_PX)).toBe(true);
  });

  it('is false a hair under the intoxication boundary', () => {
    expect(fightEligible(FIGHT_MIN_INTOX - 0.001, FIGHT_MIN_INTOX, FIGHT_MIN_DIST_PX)).toBe(false);
    expect(fightEligible(FIGHT_MIN_INTOX, FIGHT_MIN_INTOX - 0.001, FIGHT_MIN_DIST_PX)).toBe(false);
  });

  it('gates on the tipsy/loose boundary, so a loose pair qualifies', () => {
    expect(drunkStage(FIGHT_MIN_INTOX)).toBe('loose');
    expect(drunkStage(FIGHT_MIN_INTOX - 0.001)).toBe('tipsy');
    expect(fightEligible(0.5, 0.5, 30)).toBe(true);
    expect(fightEligible(0.44, 0.44, 30)).toBe(false);
  });
});
