/**
 * Goal-mode server tests: GoalsRepo state rows, and SessionManager.startGoal driving core
 * goal mode through one `session.run(input, { goal })` call with a fake Session (no real LLM
 * requests) — round events, terminal state persistence, and status transitions.
 */
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { DatabaseSync } from "node:sqlite";
import {
  assistantText,
  buildSkillsMessage,
  emptyTokenCounts,
  goalFinished,
  imageUrlMessage,
  tokenUsage,
  userText,
} from "@prismshadow/penguin-core";
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core";
import { openDatabase } from "../src/db/database.js";
import { GoalsRepo } from "../src/db/repos/goals.js";
import { SessionsRepo } from "../src/db/repos/sessions.js";
import type { SessionRow } from "../src/db/repos/sessions.js";
import { ChannelHub } from "../src/runtime/channel.js";
import type { ChannelEvent } from "../src/runtime/channel.js";
import { SessionManager } from "../src/runtime/session-manager.js";
import type { RuntimeSession } from "../src/runtime/session-manager.js";
import { SessionSources } from "../src/runtime/session-sources.js";
import { waitFor } from "./helpers.js";

const ROW: SessionRow = {
  sessionId: "session-1",
  projectId: "p1",
  agentId: "a1",
  modelId: "m1",
  provider: "custom",
  workspace: "/tmp/w",
  approvalMode: "allow-all",
  title: null,
  createdAt: "2026-07-06T00:00:00.000Z",
};

function usage(total: number): TokenCounts {
  return { cache_read: 0, cache_write: 0, output: 0, total };
}

/** A goal round's injected input, as core's loop would compose it (block + body). */
function roundInput(round: number, body: string): OmniMessage {
  return userText(`[goal]\nround: ${round}\nprotocol lines\n[/goal]\n\n${body}`);
}

describe("GoalsRepo", () => {
  let db: DatabaseSync;
  let repo: GoalsRepo;

  beforeEach(() => {
    db = openDatabase(":memory:");
    repo = new GoalsRepo(db);
  });
  afterEach(() => db.close());

  it("creates, progresses, finishes, and reads back the latest row per session", () => {
    const id = repo.create({
      sessionId: "s1",
      projectId: "p1",
      agentId: "a1",
      objective: "obj",
      budget: -1,
    });
    repo.progress(id, 2, 1234);
    let row = repo.latestForSession("s1");
    expect(row).toMatchObject({ id, status: "active", rounds: 2, used: 1234, budget: -1 });

    repo.finish(id, "complete", 3, 2000);
    row = repo.latestForSession("s1");
    expect(row).toMatchObject({ status: "complete", rounds: 3, used: 2000 });

    // A later run wins for display.
    const id2 = repo.create({
      sessionId: "s1",
      projectId: "p1",
      agentId: "a1",
      objective: "obj2",
      budget: 500,
    });
    expect(repo.latestForSession("s1")?.id).toBe(id2);
  });

  it("deletes by session, by agent, and by project", () => {
    repo.create({ sessionId: "s1", projectId: "p1", agentId: "a1", objective: "o", budget: -1 });
    repo.create({ sessionId: "s2", projectId: "p1", agentId: "a1", objective: "o", budget: -1 });
    repo.deleteBySession("s1");
    expect(repo.latestForSession("s1")).toBeNull();
    expect(repo.latestForSession("s2")).not.toBeNull();

    // deleteByAgent drops the agent's rows but spares another agent in the same project.
    repo.create({ sessionId: "s3", projectId: "p1", agentId: "a2", objective: "o", budget: -1 });
    repo.deleteByAgent("p1", "a1");
    expect(repo.latestForSession("s2")).toBeNull();
    expect(repo.latestForSession("s3")).not.toBeNull();

    repo.deleteByProject("p1");
    expect(repo.latestForSession("s3")).toBeNull();
  });

  it("reconciles orphaned active rows to aborted on startup, leaving terminal rows untouched", () => {
    const active = repo.create({
      sessionId: "s1",
      projectId: "p1",
      agentId: "a1",
      objective: "o",
      budget: -1,
    });
    const done = repo.create({
      sessionId: "s2",
      projectId: "p1",
      agentId: "a1",
      objective: "o",
      budget: -1,
    });
    repo.finish(done, "complete", 1, 10);

    // A hard crash leaves the running goal's row `active`; boot reconciliation flips only it.
    expect(repo.abortOrphanedActive()).toBe(1);
    expect(repo.latestForSession("s1")?.status).toBe("aborted");
    expect(repo.latestForSession("s2")?.status).toBe("complete");
    // Idempotent: a second boot finds nothing left to reconcile.
    expect(repo.abortOrphanedActive()).toBe(0);
    void active;
  });
});

describe("SessionManager.startGoal", () => {
  let db: DatabaseSync;
  let sessions: SessionsRepo;
  let goals: GoalsRepo;
  let channels: ChannelHub;

  beforeEach(() => {
    db = openDatabase(":memory:");
    sessions = new SessionsRepo(db);
    sessions.insert(ROW);
    goals = new GoalsRepo(db);
    channels = new ChannelHub();
  });
  afterEach(() => {
    channels.dispose();
    db.close();
  });

  type RunOpts = { thinkingLevel?: string; goal?: { budget?: number } };

  /**
   * Fake session: `run` asserts it was called in goal mode and emits the whole goal stream
   * the way core's loop would — per-round `[goal]` inputs and work, then the terminal
   * goal_finished event (the loop itself is core's and is tested in core).
   */
  function goalFakeSession(
    stream: (input: OmniMessage[]) => OmniMessage[],
  ): RuntimeSession & { runOpts: RunOpts[]; runs: OmniMessage[][] } {
    const runOpts: RunOpts[] = [];
    const runs: OmniMessage[][] = [];
    return {
      sessionId: ROW.sessionId,
      runOpts,
      runs,
      toolPermission: () => "rw",
      generateTitle: async () => ({ title: null, usage: null }),
      compactability: () => "ok" as const,
      steer: () => false,
      skipReconnectWait: () => false,
      async *run(input: OmniMessage[], opts) {
        runs.push(input);
        runOpts.push({
          ...(opts.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
          ...(opts.goal !== undefined ? { goal: opts.goal } : {}),
        });
        yield* stream(input);
      },
      async *compact() {},
    };
  }

  function makeManager(session: RuntimeSession, withRepo = true): SessionManager {
    return new SessionManager({
      sessions,
      channels,
      sources: new SessionSources(),
      loader: { load: async () => session },
      recorder: { record: async () => {} },
      log: () => {},
      ...(withRepo ? { goals } : {}),
    });
  }

  it("drives one goal-mode run, publishing goal events and persisting the outcome", async () => {
    const text = buildSkillsMessage(["web-design"], "make it work");
    const session = goalFakeSession((input) => [
      roundInput(1, (input[0]!.payload as { text: string }).text),
      assistantText("round 1 work"),
      tokenUsage(usage(100), usage(100)),
      roundInput(2, "make it work"),
      assistantText("round 2 work"),
      tokenUsage(usage(200), usage(200)),
      goalFinished("complete", 2, 300),
    ]);
    const manager = makeManager(session);
    const events: ChannelEvent[] = [];
    channels.get(ROW.sessionId).subscribe((e) => events.push(e));

    await manager.startGoal(ROW.sessionId, {
      input: [userText(text)],
      budget: -1,
      thinkingLevel: "high",
    });
    await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");

    // One run call carries the whole goal: the input verbatim, the per-goal thinking
    // level, and the goal option (core loops the rounds internally).
    expect(session.runOpts).toEqual([{ thinkingLevel: "high", goal: { budget: -1 } }]);

    const server = events
      .filter((e) => e.event === "server_event")
      .map((e) => JSON.parse(e.data) as { type: string; [k: string]: unknown });
    // The recorded objective is the user's own text: the [use_skills] prefix is stripped.
    expect(server.find((e) => e.type === "goal_started")).toMatchObject({
      objective: "make it work",
      budget: -1,
    });
    const rounds = server.filter((e) => e.type === "goal_round");
    expect(rounds).toHaveLength(2);
    expect(rounds[1]).toMatchObject({ round: 2, used: 100 });
    const finished = server.find((e) => e.type === "goal_finished");
    expect(finished).toMatchObject({ outcome: "complete", rounds: 2, used: 300 });

    const row = goals.latestForSession(ROW.sessionId);
    expect(row).toMatchObject({
      status: "complete",
      rounds: 2,
      used: 300,
      objective: "make it work",
    });

    // The round inputs were published on the message stream (no `event:` name) for live viewers.
    const published = events
      .filter((e) => e.event === undefined)
      .map((e) => JSON.parse(e.data) as OmniMessage)
      .filter(
        (m) =>
          m.type === "model_msg" &&
          (m.payload as { role?: string }).role === "user" &&
          ((m.payload as { text?: string }).text ?? "").startsWith("[goal]"),
      );
    expect(published).toHaveLength(2);
    // Round 1 carries the caller's input verbatim — the [use_skills] block included.
    expect((published[0]!.payload as { text: string }).text).toContain("[use_skills]");
  });

  it("records the objective without the attached images: the display copy stays path-free", async () => {
    // Core folds the attached images into `[attached image: …]` lines inside the objective it
    // re-injects each round. The objective recorded here is the one shown to people — status
    // card, goal_started, title material — so it keeps the user's words only.
    const session = goalFakeSession(() => [goalFinished("complete", 1, 10)]);
    const manager = makeManager(session);
    const events: ChannelEvent[] = [];
    channels.get(ROW.sessionId).subscribe((e) => events.push(e));

    await manager.startGoal(ROW.sessionId, {
      input: [userText("Match this mockup"), imageUrlMessage("data:image/png;base64,aGk=")],
      budget: -1,
    });
    await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");

    // The whole input still reaches core (the images included) — only the recorded copy differs.
    expect(session.runs[0]).toHaveLength(2);
    const started = events
      .filter((e) => e.event === "server_event")
      .map((e) => JSON.parse(e.data) as { type: string; objective?: string })
      .find((e) => e.type === "goal_started");
    expect(started?.objective).toBe("Match this mockup");
    expect(goals.latestForSession(ROW.sessionId)?.objective).toBe("Match this mockup");
  });

  it("409s while a goal is running (mutual exclusion); runs without a goals repo", async () => {
    let release: () => void = () => {};
    const gate = new Promise<void>((r) => {
      release = r;
    });
    const session = goalFakeSession(() => [roundInput(1, "obj"), goalFinished("complete", 1, 0)]);
    const orig = session.run.bind(session);
    session.run = async function* (input, opts) {
      yield* orig(input, opts);
      await gate;
    };
    const manager = makeManager(session, false);
    await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: -1 });
    await expect(manager.startTask(ROW.sessionId, [userText("x")])).rejects.toMatchObject({
      status: 409,
    });
    release();
    await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");
    // No goals repo wired: the goal still ran and finished without touching one.
    expect(goals.latestForSession(ROW.sessionId)).toBeNull();
  });

  it("a throw after the terminal event does not overwrite the recorded outcome", async () => {
    // repo.finish is an unconditional UPDATE: without the `finished` guard, the defensive
    // catch would flip a completed row to aborted and publish a contradicting event.
    const session = goalFakeSession(() => []);
    session.run = async function* () {
      yield roundInput(1, "obj");
      yield goalFinished("complete", 1, 42);
      throw new Error("post-terminal hiccup");
    };
    const manager = makeManager(session);
    const events: ChannelEvent[] = [];
    channels.get(ROW.sessionId).subscribe((e) => events.push(e));

    await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: -1 });
    await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");

    expect(goals.latestForSession(ROW.sessionId)).toMatchObject({
      status: "complete",
      rounds: 1,
      used: 42,
    });
    const finished = events
      .filter((e) => e.event === "server_event")
      .map((e) => JSON.parse(e.data) as { type: string; outcome?: string })
      .filter((e) => e.type === "goal_finished");
    expect(finished).toEqual([expect.objectContaining({ outcome: "complete" })]);
  });

  it("closes the run state as aborted when the stream ends without a terminal event", async () => {
    // A cut-off run (infrastructure failure upstream) must not leave the row active.
    const session = goalFakeSession(() => [
      roundInput(1, "obj"),
      assistantText("partial work"),
      tokenUsage(usage(50), usage(50)),
    ]);
    const manager = makeManager(session);
    const events: ChannelEvent[] = [];
    channels.get(ROW.sessionId).subscribe((e) => events.push(e));

    await manager.startGoal(ROW.sessionId, { input: [userText("obj")], budget: 1000 });
    await waitFor(() => manager.statusOf(ROW.sessionId) === "idle");

    expect(goals.latestForSession(ROW.sessionId)).toMatchObject({
      status: "aborted",
      rounds: 1,
      used: 50,
    });
    const server = events
      .filter((e) => e.event === "server_event")
      .map((e) => JSON.parse(e.data) as { type: string; [k: string]: unknown });
    expect(server.find((e) => e.type === "goal_finished")).toMatchObject({
      outcome: "aborted",
      rounds: 1,
      used: 50,
    });
  });

  it("sanity: emptyTokenCounts helper stays exported for fakes", () => {
    expect(emptyTokenCounts().total).toBe(0);
  });
});
