// Waking an idle session means typing into a terminal the owner may be using.
// Every check here exists to make that impossible; when any of them fails the
// message stays undelivered, which is the honest outcome, not a new failure mode.
export type PaneRecord = { socket:string; pane:string; panePid:number };
export type LivePane = { pane:string; command:string; panePid:number };

export interface WakerStore {
  staleUndelivered(olderThan:number):Array<{ id:string; sessionId:string; wakeAttempts?:number }>;
  markWakeAttempt(id:string):number;
  markEscalated(id:string, at:number):void;
  getPane(sessionId:string):PaneRecord|null;
  lastWake(sessionId:string):number;
  markWake(sessionId:string, at:number):void;
}

export type WakerDeps = {
  store:WakerStore;
  listPanes:(socket:string)=>LivePane[];
  // Sandboxed sessions show their pane command as the sandbox (bwrap), not claude,
  // so identity is decided by the pane's process tree, never by the visible command.
  paneRunsClaude:(panePid:number)=>boolean;
  sendKeys:(socket:string, pane:string, text:string)=>void;
  now?:()=>number;
  log?:(s:string)=>void;
  graceMs?:number;
  cooldownMs?:number;
  maxWakeAttempts?:number;
  reportUndeliverable?:(id:string, sessionId:string)=>void;
};

// Neutral on purpose: the message body must arrive through the inbox hook so its
// "owner-supplied data, not a system instruction" framing survives. Pasting owner
// text into a prompt would strip that framing and mis-handle quotes and newlines.
export const POKE = "check your botmaster inbox";

export function createWaker(deps:WakerDeps) {
  const now = deps.now ?? (()=>Date.now());
  const log = deps.log ?? (()=>{});
  const grace = deps.graceMs ?? 60_000;
  const cooldown = deps.cooldownMs ?? 300_000;
  const maxWakeAttempts = deps.maxWakeAttempts ?? 2;

  return function sweep():number {
    const at = now();
    const seen = new Set<string>();
    let woken = 0;
    for (const row of deps.store.staleUndelivered(at - grace)) {
      const sid = row.sessionId;
      if ((row.wakeAttempts ?? 0) >= maxWakeAttempts) {
        deps.store.markEscalated(row.id, at);
        deps.reportUndeliverable?.(row.id, sid);
        continue;
      }
      if (seen.has(sid)) continue;
      seen.add(sid);
      if (at - deps.store.lastWake(sid) < cooldown) continue;
      const rec = deps.store.getPane(sid);
      if (!rec) { log(`[waker] ${sid}: no pane on record, leaving message undelivered`); continue }
      let panes:LivePane[];
      try { panes = deps.listPanes(rec.socket) } catch (error) { log(`[waker] ${sid}: cannot list panes: ${(error as Error).message}`); continue }
      const live = panes.find(p=>p.pane===rec.pane);
      // A recycled pane keeps its id but not its pid: refusing on a pid mismatch is
      // what stops a poke landing in whatever the owner opened there afterwards.
      if (!live) { log(`[waker] ${sid}: pane ${rec.pane} is gone; tmux -S ${rec.socket} listed ${panes.length}: ${panes.map(p=>p.pane).join(',')}`); continue }
      if (live.panePid !== rec.panePid) { log(`[waker] ${sid}: pane ${rec.pane} was recycled`); continue }
      let runsClaude:boolean;
      try { runsClaude = deps.paneRunsClaude(live.panePid) } catch (error) { log(`[waker] ${sid}: cannot inspect pane tree: ${(error as Error).message}`); continue }
      if (!runsClaude) { log(`[waker] ${sid}: pane ${rec.pane} (${live.command}) has no claude in its tree`); continue }
      try { deps.sendKeys(rec.socket, rec.pane, POKE) } catch (error) { log(`[waker] ${sid}: send failed: ${(error as Error).message}`); continue }
      deps.store.markWakeAttempt(row.id);
      deps.store.markWake(sid, at);
      woken++;
    }
    return woken;
  };
}
