import type { Stage } from './scenes/stage';
import { FLOOR_Y } from './scenes/stage';
import { spawnFood, FOODS } from './sim/food';
import { DAYS } from './game/game';

/**
 * Dev harness — toastsim's single most valuable organ, transplanted.
 *
 * Drives the REAL sim deterministically at a fixed dt so "does it actually work"
 * is answered by measured numbers, not by squinting. Every helper returns a
 * stats object. Installed on window.__s in dev builds. Milestones add verbs;
 * nothing ships without a harness number behind it.
 */
const STAGE_UNIT = 40; // px per "stage-unit" — the walk bar is measured in these
const HOME_X = 400;

export function installHarness(stage: Stage): void {
  const p = () => stage.puppet;
  const M = () => stage.matter.body;

  const step = (frames: number, dt = 1000 / 60): void => {
    stage.auto = false;
    for (let i = 0; i < frames; i++) stage.stepSim(dt);
  };

  /** Hold the bar at (x,y) for `frames`, stepping each frame (no tilt). */
  const hold = (x: number, y: number, frames: number, grip = false): void => {
    stage.auto = false;
    for (let i = 0; i < frames; i++) {
      p().setBar(x, y, 0);
      p().setGrip(grip);
      stage.stepSim();
    }
  };

  /** Hold the bar at (x, home) until the hips arrive under it (body drifts to
   *  follow the bar — the arms hang from the shoulders, so the body has to get
   *  there first). Caps at maxFrames. */
  const shiftBodyTo = (x: number, maxFrames = 180): number => {
    stage.auto = false;
    let i = 0;
    for (; i < maxFrames; i++) {
      p().setBar(x, p().barHomeY, 0);
      p().setGrip(false);
      stage.stepSim();
      if (Math.abs(p().torsoX - x) < 14 && p().restSpeed() < 1.2) break;
    }
    return i;
  };

  /** Frames until the swing steadies. Soft strings keep a marionette gently
   *  alive forever, so "settled" means the energy has plateaued (stopped
   *  falling), not literal zero. Returns frames-to-steady. */
  const settle = (maxFrames = 600): number => {
    stage.auto = false;
    let f = 0, low = Infinity, plateau = 0;
    for (; f < maxFrames; f++) {
      stage.stepSim();
      const v = p().restSpeed();
      if (v < low - 0.02) { low = v; plateau = 0; } else if (++plateau > 45) break;
      if (v < 0.15) break;
    }
    return f;
  };

  const placeCrate = (x: number): void => {
    M().setPosition(stage.crate, { x, y: FLOOR_Y - 24 }, false); // rest flush on the floor
    M().setAngle(stage.crate, 0, false);
    M().setVelocity(stage.crate, { x: 0, y: 0 });
    M().setAngularVelocity(stage.crate, 0);
  };

  const s = {
    stage,
    step,
    settle,
    pose: () => p().pose(),

    /** Dismiss the title overlay (for gameplay screenshots / headless drives). */
    startGame: () => stage.scene.stop('title'),

    /** Set the control bar to an absolute position and step one frame. */
    bar(x: number, y = p().barHomeY) {
      stage.auto = false;
      p().setBar(x, y, 0);
      stage.stepSim();
      return p().pose();
    },

    grip(on: boolean) {
      stage.auto = false;
      p().setGrip(on);
      stage.stepSim();
      return { gripping: p().gripping, hands: p().handWorld() };
    },

    /** Oscillate the bar around home: freq Hz, amp px, for secs seconds. */
    sway(freq = 1.2, amp = 90, secs = 3) {
      stage.auto = false;
      const frames = Math.round(secs * 60);
      for (let i = 0; i < frames; i++) {
        const x = HOME_X + amp * Math.sin((2 * Math.PI * freq * i) / 60);
        p().applyInput(x, p().barHomeY, false, false);
        stage.stepSim();
      }
      return p().pose();
    },

    /** THE DANGLE (M1 feel gate): yank the bar aside, let go, measure the swing
     *  home. Long settle + a clean decay = a puppet that has real pendulum. */
    dangle() {
      hold(HOME_X, p().barHomeY, 30); // start at rest
      hold(HOME_X + 220, p().barHomeY, 12); // yank right
      p().setBar(HOME_X, p().barHomeY, 0); // release to home
      const frames = settle();
      return { settleFrames: frames, ...p().pose() };
    },

    /** THE WALK (M1 exit-bar): a rhythmic sway whose centre drifts `dir`, so the
     *  legs swing and the puppet is dragged along. Reports units travelled and
     *  whether it stayed upright the whole way. */
    walk(dir = 1, secs = 8, freq = 1.2, amp = 45, advance = 460) {
      hold(HOME_X, p().barHomeY, 20);
      const x0 = p().torsoX;
      const frames = Math.round(secs * 60);
      // A faceplant is the head hitting the floor, not a transient stride-lean —
      // a lively marionette swings past 45° mid-step and recovers. Track how low
      // the head ever got; the end pose says whether it's still walking.
      let lowestHead = 0;
      for (let i = 0; i < frames; i++) {
        const center = HOME_X + dir * advance * (i / frames);
        const x = center + amp * Math.sin((2 * Math.PI * freq * i) / 60);
        p().applyInput(x, p().barHomeY, false, false);
        stage.stepSim();
        lowestHead = Math.max(lowestHead, p().seg.head.position.y);
      }
      const dx = p().torsoX - x0;
      const finalPose = p().pose();
      const faceplant = lowestHead > FLOOR_Y - 70 || !finalPose.upright;
      return {
        units: +(dx / STAGE_UNIT).toFixed(2),
        dx: +dx.toFixed(1),
        upright: !faceplant,
        faceplant,
        lean: finalPose.lean,
        lowestHead: +lowestHead.toFixed(0),
      };
    },

    /** Reach down over the crate, grip, lift — did the crate leave the floor?
     *  The arms hang from the shoulders, not the bar, so a sideways yank just
     *  stretches them. Instead aim the bar AT the crate and hold until the whole
     *  body drifts over it — then the hands hang right onto the crate. */
    pickup(crateX = 470) {
      placeCrate(crateX);
      shiftBodyTo(crateX); // walk the body over the crate before reaching
      hold(crateX, p().barHomeY + 150, 40, true); // reach down + clamp (sticky grabs on contact)
      const grabbedY = stage.crate.position.y;
      hold(crateX, p().barHomeY - 30, 50, true); // lift
      const lift = grabbedY - stage.crate.position.y;
      const held = stage.crate.position.y < FLOOR_Y - 40;
      p().setGrip(false);
      return { lift: +lift.toFixed(1), crateY: +stage.crate.position.y.toFixed(1), held };
    },

    /** Grip must lift the crate 10/10. Each trial resets to the identical home
     *  pose so trials are independent (no drift accumulating across grabs). */
    pickupTest(n = 10) {
      let ok = 0;
      const lifts: number[] = [];
      for (let i = 0; i < n; i++) {
        stage.auto = false;
        p().reset(HOME_X);
        settle();
        const r = this.pickup(440 + (i % 5) * 15);
        if (r.held) ok++;
        lifts.push(r.lift);
      }
      return { ok, n, rate: `${ok}/${n}`, lifts };
    },

    // ---- M2: cutting -------------------------------------------------------
    cutStats: () => stage.cutter.stats(),

    /** Clear the board and drop a fresh food, settled on it. */
    spawnFood(kind = 'tomato', x = 760) {
      stage.auto = false;
      for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
      stage.cutter.foods = [];
      stage.cutter.splats = [];
      stage.cutter.last = null;
      stage.cutter.add(spawnFood(stage, kind, x, 512));
      for (let i = 0; i < 45; i++) stage.stepSim();
      return stage.cutter.stats();
    },

    /** Drive the blade tip [x,y]→[x,y] over `secs`, then settle. Returns cutStats. */
    swing(from: [number, number], to: [number, number], secs = 0.18, settleAfter = 30) {
      stage.auto = false;
      stage.cutter.resetBlade({ x: from[0], y: from[1] });
      const frames = Math.max(2, Math.round(secs * 60));
      for (let i = 1; i <= frames; i++) {
        const t = i / frames;
        stage.cutter.moveBlade({ x: from[0] + (to[0] - from[0]) * t, y: from[1] + (to[1] - from[1]) * t });
        stage.stepSim();
      }
      for (let i = 0; i < settleAfter; i++) stage.stepSim();
      return stage.cutter.stats();
    },

    /** M2 exit-bar A: a practiced fast chop through the centre → 2 clean pieces, CV < 0.1. */
    chop(kind = 'tomato') {
      this.spawnFood(kind);
      const c = stage.cutter.foods[0].body.position;
      return this.swing([c.x, c.y - 70], [c.x, c.y + 80], 0.18);
    },

    /** M2 exit-bar B: a slow press → no cut, a wet splat. */
    push(kind = 'tomato') {
      this.spawnFood(kind);
      const c = stage.cutter.foods[0].body.position;
      return this.swing([c.x, c.y - 70], [c.x, c.y + 80], 1.5);
    },

    /** M2 exit-bar C: a wild fast swing → a piece flung offstage (and that's CORRECT). */
    wildChop(kind = 'tomato') {
      this.spawnFood(kind);
      const c = stage.cutter.foods[0].body.position;
      const r = this.swing([c.x - 110, c.y], [c.x + 320, c.y], 0.08, 55);
      const offstage = stage.cutter.foods.some((f) => f.body.position.x < 20 || f.body.position.x > 1260 || f.body.position.y > 705);
      return { ...r, offstage, at: stage.cutter.foods.map((f) => ({ x: +f.body.position.x.toFixed(0), y: +f.body.position.y.toFixed(0) })) };
    },

    // ---- M3: stacking ------------------------------------------------------
    stack: () => stage.sando.stats(),

    /** Build a fresh sando: one layer per (kind, offsetX) pair. */
    buildSando(spec: [string, number][] = [['bread', 0], ['tomato', 0], ['bread', 0]]) {
      stage.auto = false;
      stage.sando.clear();
      for (const [kind, off] of spec) stage.sando.addLayer(kind, off);
      for (let i = 0; i < 60; i++) stage.stepSim(); // let the whole tower settle
      return stage.sando.stats();
    },

    /** Earthquake: shove the stack sideways by `force` (px/step), then settle. */
    nudge(force = 3) {
      stage.auto = false;
      stage.sando.nudge(force);
      for (let i = 0; i < 90; i++) stage.stepSim();
      return stage.sando.stats();
    },

    /** M3 exit-bar: a centred 3-layer sando stands and survives the quake. */
    stackGood() {
      const built = this.buildSando([['bread', 0], ['tomato', 0], ['bread', 0]]);
      const quaked = this.nudge(3);
      return { built, quaked, survived: quaked.standing };
    },

    /** M3 exit-bar: a leaning stack stands marginally, then the quake topples it. */
    stackBad() {
      const built = this.buildSando([['bread', 0], ['tomato', 20], ['bread', 40]]);
      const stoodBefore = built.standing;
      const quaked = this.nudge(3);
      return { built, quaked, stoodBefore, toppledByQuake: stoodBefore && !quaked.standing };
    },

    // ---- M4: the samurai + the full loop -----------------------------------
    samurai: () => ({
      anger: +stage.session.samurai.anger.toFixed(2),
      stage: stage.session.samurai.stage,
      portrait: stage.session.samurai.portrait,
      struck: stage.session.samurai.struck,
    }),

    /** Feed the anger meter a kitchen sin. */
    anger(ev: 'drop' | 'splat' | 'crimeCut' | 'servedWrong' | 'servedLate' | 'offstage' | 'cleanServe') {
      stage.session.samurai.bump(ev);
      return this.samurai();
    },

    /** Score the current sando as served + show the scorecard. */
    serve(bowHeld = true, seconds = 18) {
      const v = stage.session.serve(bowHeld, seconds);
      stage.showScorecard(v);
      return { grade: v.grade, total: v.total, lines: v.lines, anger: +stage.session.samurai.anger.toFixed(2) };
    },

    /** Wipe the board and stack, reset the samurai + puppet to a clean start. */
    freshRun() {
      stage.auto = false;
      stage.clearScorecard();
      stage.session.reset();
      stage.puppet.reset(400);
      settle();
      for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
      stage.cutter.foods = [];
      stage.cutter.splats = [];
      stage.cutter.last = null;
      stage.sando.clear();
    },

    /** M4 exit-bar A: order → cut → stack → serve, scored. Should grade B+. */
    playCleanRun() {
      this.freshRun();
      this.chop('tomato'); // 2 clean pieces
      this.buildSando([['bread', 0], ['tomato', 0], ['bread', 0]]);
      const v = this.serve(true, 18);
      return { ...v, standing: stage.sando.stats().standing, order: stage.session.order.name };
    },

    /** Proves the held-cleaver → live-cut wiring: with the cleaver in the hand,
     *  a swing through food cuts it (a marionette arm easily clears the cut
     *  speed). In play the challenge is getting the blade there and steady. */
    swingCleaver() {
      this.freshRun();
      const M = stage.matter;
      const arm = stage.puppet.seg.lArmR;
      M.body.setPosition(stage.cleaver, { x: arm.position.x, y: arm.position.y + 40 }, false);
      const weld = M.add.constraint(arm, stage.cleaver, 0, 0.9, { pointA: { x: 0, y: 20 }, pointB: { x: 0, y: -32 } });
      for (const f of [...stage.cutter.foods]) stage.removeProp(f.body);
      stage.cutter.foods = [];
      stage.cutter.last = null;
      const foodX = stage.puppet.seg.hips.position.x + 55;
      this.spawnFood('tomato', foodX);
      let peak = 0, prev = stage.cleaverTip();
      const whip = (bx: number, by: number, n: number) => {
        for (let i = 0; i < n; i++) {
          p().setBar(bx, by, 0);
          stage.stepSim();
          stage.cutter.moveBlade(stage.cleaverTip());
          const t = stage.cleaverTip();
          peak = Math.max(peak, Math.hypot(t.x - prev.x, t.y - prev.y));
          prev = t;
        }
      };
      for (let r = 0; r < 6 && stage.cutter.foods.length < 2; r++) {
        whip(foodX + 20, 60, 6);
        whip(foodX, 330, 6);
      }
      M.world.remove(weld);
      const st = stage.cutter.stats();
      return { cut: stage.cutter.foods.length >= 2, pieces: st.pieces, peakTipSpeed: +peak.toFixed(1), quality: st.last?.quality };
    },

    // ---- M5: days + escalation ---------------------------------------------
    /** Play day `n` with a scripted-but-honest run: cut the filling, stack the
     *  ordered layers, serve in time. Each day must stay beatable (B+). */
    playDay(n = 0) {
      this.freshRun();
      const d = stage.session.setDay(n);
      const order = stage.session.order;
      const fill = order.layers.find((k) => k !== 'bread' && k in FOODS) ?? 'tomato';
      this.chop(fill); // a clean cut feeds THE CUT
      this.buildSando(order.layers.map((k) => [k, 0] as [string, number]));
      const v = this.serve(true, Math.max(6, order.maxSeconds - 6));
      return {
        day: n + 1,
        name: order.name,
        pressure: d.pressure,
        grade: v.grade,
        total: v.total,
        standing: stage.sando.stats().standing,
      };
    },

    /** M5 exit-bar: every day introduces its pressure and is beatable (B+). */
    playAllDays() {
      return DAYS.map((_, i) => this.playDay(i));
    },

    /** M4 exit-bar B: anger the samurai to the top → the string cut → ragdoll. */
    playDisasterRun() {
      this.freshRun();
      const headY0 = stage.puppet.seg.head.position.y;
      let bumps = 0;
      while (!stage.session.samurai.struck && bumps < 30) {
        stage.session.samurai.bump('crimeCut');
        bumps++;
      }
      for (let i = 0; i < 120; i++) stage.stepSim(); // let the ragdoll collapse + food fly
      const headY = stage.puppet.seg.head.position.y;
      return {
        struck: stage.session.samurai.struck,
        stringsCut: stage.puppet.cut,
        bumps,
        ragdollFell: +(headY - headY0).toFixed(0),
        anger: +stage.session.samurai.anger.toFixed(2),
      };
    },
  };

  (window as { __s?: typeof s }).__s = s;
  // eslint-disable-next-line no-console
  console.log('[dev] __s ready — __s.dangle() __s.walk() __s.pickupTest()');
}
