// Stimulus / reaction system. To add a new environment reaction:
//   1. Add a `ReactionDef` entry to REACTIONS below.
//   2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
//      inside `attachStimuli()`'s per-session bundle.
// The dispatch logic (priority + cooldown + interruptsSleep) is generic
// over REACTIONS — no engine change is needed for a new reaction.
//
// Reactions are dispatched into the MascotLayer via the `emit` callback
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
// durationMs}) and sets the bubble (optionally overridden per-dispatch —
// see the `bubbleOverride` param — so e.g. eureka can show the actual
// knowledge title instead of a generic line). `dragged` always wins over
// any reaction; `sleep` is broken only when `interruptsSleep` is true.
//
// Scoping: everything below tracks whichever task/chat window currently has
// focus (windows.ts's focusedSessionId), re-bound every time focus moves —
// the mascot reacts to the task the operator is actually looking at, not to
// every session fleet-wide. Nothing fires while no task window is focused.
//
// `thinking`/`talking` aren't pulse reactions at all: they're the
// continuous `busy` FSM behavior (behavior.ts), driven by the second
// `setBusy` callback rather than `emit` — see the "Busy" block below.

import { derived } from 'svelte/store'
import { chatFor } from '$lib/stores/chat'
import { workspaceFor } from '$lib/stores/workspace'
import { activityLogFor } from '$lib/stores/activity'
import { focusedSessionId } from '$lib/stores/windows'
import { grantXp } from './state.svelte'
import type { AnimName } from './types'

export interface ReactionDef {
  id: string
  /** Animation to play while this reaction is active. */
  anim: AnimName
  /** Optional text/emoji shown above the mascot in a real speech bubble while this reaction plays (see MascotRuntime.bubbleText). Per-dispatch callers may override this — see `tryDispatch`'s bubbleOverride param. */
  bubble?: string
  /** Higher priority interrupts lower-priority reactions. */
  priority: number
  /** Minimum ms between dispatches of this same reaction. */
  cooldownMs: number
  /** How long the reaction animation plays (ms). */
  durationMs: number
  /** When true, breaks the mascot out of `sleep` to play the reaction. */
  interruptsSleep?: boolean
  /** Side effect to run on dispatch (e.g. grantXp(5) on eureka). */
  effect?: () => void
}

export const REACTIONS: Record<string, ReactionDef> = {
  thinking: {
    id: 'thinking',
    anim: 'react-think',
    bubble: '💭 Thinking…',
    priority: 1,
    cooldownMs: 0,
    durationMs: 4000,
    interruptsSleep: false
  },
  eureka: {
    id: 'eureka',
    anim: 'react-eureka',
    bubble: '💡 Eureka!',
    priority: 2,
    cooldownMs: 3000,
    durationMs: 2200,
    interruptsSleep: false,
    effect: () => grantXp(5)
  },
  alarmed: {
    id: 'alarmed',
    anim: 'react-alarm',
    bubble: '❗ Need your OK',
    priority: 3,
    cooldownMs: 3000,
    durationMs: 2500,
    interruptsSleep: true
  },
  happy: {
    id: 'happy',
    anim: 'react-happy',
    bubble: '🎉 Nice work!',
    priority: 1,
    cooldownMs: 3000,
    durationMs: 2000
  }
}

/** Per-reaction last-dispatched timestamp (ms). */
const lastFired = new Map<string, number>()

/** Tracks the current reaction's priority so a lower-priority one can't interrupt a higher one mid-flight. */
let currentReactPriority = 0
let currentReactUntil = 0

const BUBBLE_MAX = 70

/** Truncate a dynamic bubble line (a knowledge title, a task summary) to something that still fits the speech bubble. */
function truncate(s: string, max = BUBBLE_MAX): string {
  return s.length > max ? `${s.slice(0, max - 1)}…` : s
}

/**
 * Attach all stimulus subscriptions. Returns a teardown that detaches
 * everything. `emit` is the MascotLayer's bridge for pulse reactions
 * (eureka/alarmed/happy — priority+cooldown gated, see tryDispatch);
 * `setBusy` is its bridge for the continuous thinking/talking state (no
 * gating — it's a live phase, not a discrete event).
 */
export function attachStimuli(
  emit: (r: ReactionDef, bubbleOverride?: string) => void,
  setBusy: (phase: 'thinking' | 'talking' | null) => void
): () => void {
  const unsubs: Array<() => void> = []

  // Everything below is re-bound every time focus moves to a different
  // task window (or away from one entirely) — teardownSession tears down
  // the previous session's bundle before (re)building for the new one.
  let teardownSession: (() => void) | null = null
  unsubs.push(
    focusedSessionId.subscribe((sid) => {
      teardownSession?.()
      teardownSession = null
      setBusy(null)
      if (!sid) return

      const inner: Array<() => void> = []
      const chat = chatFor(sid)
      const ws = workspaceFor(sid)
      const log = activityLogFor(sid)

      // ─── Busy: thinking (composing, no text yet) vs talking (text is
      // streaming out) — see the `busy` BehaviorDef in behavior.ts. ──────
      inner.push(
        derived([chat.streaming, chat.messages], ([s, msgs]) => {
          if (!s) return null
          const last = msgs[msgs.length - 1]
          return last?.role === 'assistant' && last.text.length > 0 ? 'talking' : 'thinking'
        }).subscribe(setBusy)
      )

      // ─── Eureka (new knowledge) / Happy (task completed successfully) —
      // both derived from the session's own activity log. The store
      // recomputes wholesale on every emission (not append-only), so new
      // entries are detected by diffing ids against the last-seen set.
      // The first emission after (re)subscribing is never replayed as
      // reactions — switching focus to an already-in-progress or already-
      // done task shouldn't retroactively fire pulses for old entries. ──
      let seenIds = new Set<string>()
      let firstLogEmission = true
      inner.push(
        log.subscribe((entries) => {
          const nextIds = new Set<string>()
          for (const e of entries) {
            nextIds.add(e.id)
            if (firstLogEmission || seenIds.has(e.id)) continue
            if (e.type === 'knowledge') {
              tryDispatch(
                REACTIONS.eureka,
                emit,
                `💡 ${truncate(e.description.replace(/^Recorded: /, ''))}`
              )
            } else if (e.type === 'complete' && e.status !== 'failed') {
              tryDispatch(REACTIONS.happy, emit, `🎉 ${truncate(e.description)}`)
            }
          }
          seenIds = nextIds
          firstLogEmission = false
        })
      )

      // ─── Alarmed: a new operator question was raised (permission
      // needed) — null -> non-null edge, same first-emission skip as
      // above (focusing a task that already has a pending question
      // shouldn't itself re-pulse the reaction). ──────────────────────
      let hadQuestion = false
      let firstQuestionEmission = true
      inner.push(
        ws.openQuestion.subscribe((q) => {
          if (!firstQuestionEmission && q && !hadQuestion) {
            tryDispatch(REACTIONS.alarmed, emit)
          }
          hadQuestion = q !== null
          firstQuestionEmission = false
        })
      )

      teardownSession = () => {
        for (const u of inner) u()
      }
    })
  )
  unsubs.push(() => teardownSession?.())

  return () => {
    for (const u of unsubs) u()
  }
}

/** Cooldown + priority gate before handing the reaction to MascotLayer. */
function tryDispatch(
  r: ReactionDef,
  emit: (r: ReactionDef, bubbleOverride?: string) => void,
  bubbleOverride?: string
): void {
  const now = performance.now()
  const last = lastFired.get(r.id) ?? 0
  if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
  // Priority: a new reaction must have priority >= the current one's,
  // unless the current one has expired (now > currentReactUntil).
  const currentExpired = now > currentReactUntil
  if (!currentExpired && r.priority < currentReactPriority) return
  lastFired.set(r.id, now)
  currentReactPriority = r.priority
  currentReactUntil = now + r.durationMs
  emit(r, bubbleOverride)
}

/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
export function resetStimuliState(): void {
  lastFired.clear()
  currentReactPriority = 0
  currentReactUntil = 0
}
