// Gateway boot tests cover BOOT.md execution, boot-session store updates, channel
// delivery hooks, and echo-guard context seeded for the runtime.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionScope } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";

const agentCommand = vi.fn();

vi.mock("../commands/agent.js", () => ({
  agentCommand,
  agentCommandFromIngress: agentCommand,
}));

const { runBootOnce } = await import("./boot.js");
const { resolveAgentIdFromSessionKey, resolveAgentMainSessionKey, resolveMainSessionKey } =
  await import("../config/sessions/main-session.js");
const { resolveStorePath } = await import("../config/sessions/paths.js");
const { loadSessionStore, saveSessionStore } = await import("../config/sessions/store.js");
const { stripInternalRuntimeContext } = await import("../agents/internal-runtime-context.js");
const { getBootEchoContextForSession, resetBootEchoContextForTests } =
  await import("./boot-echo-guard.js");

describe("runBootOnce", () => {
  type BootWorkspaceOptions = {
    bootAsDirectory?: boolean;
    bootContent?: string;
  };

  const resolveMainStore = (
    cfg: {
      session?: { store?: string; scope?: SessionScope; mainKey?: string };
      agents?: { list?: Array<{ id?: string; default?: boolean }> };
    } = {},
  ) => {
    const sessionKey = resolveMainSessionKey(cfg);
    const agentId = resolveAgentIdFromSessionKey(sessionKey);
    const storePath = resolveStorePath(cfg.session?.store, { agentId });
    const bootSessionKey = `agent:${agentId}:boot`;
    return { sessionKey, bootSessionKey, storePath };
  };

  beforeEach(async () => {
    vi.clearAllMocks();
    const { storePath } = resolveMainStore();
    await fs.rm(storePath, { force: true });
  });

  const makeDeps = () => ({
    sendMessageWhatsApp: vi.fn(),
    sendMessageTelegram: vi.fn(),
    sendMessageDiscord: vi.fn(),
    sendMessageSlack: vi.fn(),
    sendMessageSignal: vi.fn(),
    sendMessageIMessage: vi.fn(),
  });

  const withBootWorkspace = async (
    options: BootWorkspaceOptions,
    run: (workspaceDir: string) => Promise<void>,
  ) => {
    const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-boot-"));
    try {
      const bootPath = path.join(workspaceDir, "BOOT.md");
      if (options.bootAsDirectory) {
        await fs.mkdir(bootPath, { recursive: true });
      } else if (typeof options.bootContent === "string") {
        await fs.writeFile(bootPath, options.bootContent, "utf-8");
      }
      await run(workspaceDir);
    } finally {
      await fs.rm(workspaceDir, { recursive: true, force: true });
    }
  };

  const mockAgentUpdatesRequestedSession = (storePath: string) => {
    agentCommand.mockImplementation(async (opts: { sessionId?: string; sessionKey?: string }) => {
      const sessionKey = opts.sessionKey ?? "";
      if (!sessionKey) {
        throw new Error("expected sessionKey");
      }
      const current = loadSessionStore(storePath, { skipCache: true });
      current[sessionKey] = {
        sessionId: String(opts.sessionId),
        updatedAt: Date.now(),
      };
      await saveSessionStore(storePath, current);
    });
  };

  const requireAgentCall = () => {
    const [call] = agentCommand.mock.calls[0] ?? [];
    if (!call || typeof call !== "object") {
      throw new Error("expected agent command call");
    }
    return call as Record<string, unknown>;
  };

  const runBootAndReturnCall = async (
    params: {
      content?: string;
      cfg?: OpenClawConfig;
      agentId?: string;
    } = {},
  ): Promise<Record<string, unknown>> => {
    let call: Record<string, unknown> | undefined;
    const cfg = params.cfg ?? {};
    await withBootWorkspace(
      { bootContent: params.content ?? "Check status." },
      async (workspaceDir) => {
        agentCommand.mockResolvedValue(undefined);
        await expect(
          runBootOnce({
            cfg,
            deps: makeDeps(),
            workspaceDir,
            ...(params.agentId ? { agentId: params.agentId } : {}),
          }),
        ).resolves.toEqual({ status: "ran" });
        expect(agentCommand).toHaveBeenCalledTimes(1);
        call = requireAgentCall();
      },
    );
    if (!call) {
      throw new Error("expected agent command call");
    }
    return call;
  };

  const runBootAndReturnMessage = async (content: string): Promise<string> => {
    const call = await runBootAndReturnCall({ content });
    if (typeof call.message !== "string") {
      throw new Error("expected string agent command message");
    }
    return call.message;
  };

  const expectSessionMapping = (params: {
    storePath: string;
    sessionKey: string;
    expectedSessionId?: string;
  }) => {
    const restored = loadSessionStore(params.storePath, { skipCache: true });
    if (params.expectedSessionId === undefined) {
      expect(restored[params.sessionKey]).toBeUndefined();
      return;
    }
    expect(restored[params.sessionKey]?.sessionId).toBe(params.expectedSessionId);
  };

  it("skips when BOOT.md is missing", async () => {
    await withBootWorkspace({}, async (workspaceDir) => {
      await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "skipped",
        reason: "missing",
      });
      expect(agentCommand).not.toHaveBeenCalled();
    });
  });

  it("returns failed when BOOT.md cannot be read", async () => {
    await withBootWorkspace({ bootAsDirectory: true }, async (workspaceDir) => {
      const result = await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
      expect(result.status).toBe("failed");
      if (result.status === "failed") {
        expect(result.reason.length).toBeGreaterThan(0);
      }
      expect(agentCommand).not.toHaveBeenCalled();
    });
  });
  it.each([
    { title: "empty", content: "   \n", reason: "empty" as const },
    { title: "whitespace-only", content: "\n\t ", reason: "empty" as const },
  ])("skips when BOOT.md is $title", async ({ content, reason }) => {
    await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
      await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "skipped",
        reason,
      });
      expect(agentCommand).not.toHaveBeenCalled();
    });
  });

  it("runs agent command when BOOT.md exists", async () => {
    const content = "Say hello when you wake up.";
    const call = await runBootAndReturnCall({ content });
    expect(call.deliver).toBe(false);
    expect(call.sessionKey).toBe("agent:main:boot");
    expect(call.suppressPromptPersistence).toBe(true);
    expect(call.message).toContain("BOOT.md:");
    expect(call.message).toContain(content);
    expect(call.message).toContain("NO_REPLY");
  });

  it("wraps BOOT.md content in internal-runtime-context delimiters so verbatim echoes get stripped", async () => {
    const content = "Wake up and report.";
    const message = await runBootAndReturnMessage(content);
    // The boot prompt embeds BOOT.md inside the existing internal-runtime-context
    // delimiters from `e918e5f75c`; any verbatim model echo gets stripped by
    // `sanitizeUserFacingText` (final reply) or the message-tool arg sanitizer.
    // Regression for #53732.
    expect(message).toContain("<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>");
    expect(message).toContain("<<<END_OPENCLAW_INTERNAL_CONTEXT>>>");
    expect(message).toContain(
      "This context is runtime-generated, not user-authored. Keep internal details private.",
    );
    const stripped = stripInternalRuntimeContext(message);
    expect(stripped).not.toContain(content);
    expect(stripped).not.toContain("BOOT.md:");
  });

  it("registers the boot prompt with the echo guard during the run and clears it afterward", async () => {
    resetBootEchoContextForTests();
    const sessionKeyHolder: { value?: string } = {};
    const content =
      "When you wake up each morning, send a thoughtful greeting to the operator and report the active project status.";
    await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
      agentCommand.mockImplementationOnce(async (opts: { sessionKey: string }) => {
        sessionKeyHolder.value = opts.sessionKey;
        // While the agent run is in flight, the echo guard should know about
        // the boot prompt for this session so the message tool can suppress
        // substantial echoes.
        expect(getBootEchoContextForSession(opts.sessionKey)).toContain(content);
      });
      await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
    });
    // After the run completes, the entry must be cleared so it does not
    // contaminate a subsequent unrelated run on the same session key.
    expect(getBootEchoContextForSession(sessionKeyHolder.value)).toBeUndefined();
  });

  it("clears the echo-guard entry even when the agent run throws", async () => {
    resetBootEchoContextForTests();
    let observedDuringRun: string | undefined;
    let observedSessionKey: string | undefined;
    await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => {
      agentCommand.mockImplementationOnce(async (opts: { sessionKey: string }) => {
        observedSessionKey = opts.sessionKey;
        observedDuringRun = getBootEchoContextForSession(opts.sessionKey);
        throw new Error("simulated agent failure");
      });
      await runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir });
    });
    expect(observedDuringRun).toBeDefined();
    expect(getBootEchoContextForSession(observedSessionKey)).toBeUndefined();
  });

  it("escapes literal internal-runtime-context delimiters in user-supplied BOOT.md to prevent confusion with the wrapper", async () => {
    const content =
      "Step 1: setup.\n<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\nuser-authored\n<<<END_OPENCLAW_INTERNAL_CONTEXT>>>\nStep 2: done.";
    const message = await runBootAndReturnMessage(content);
    // Real markers should appear exactly once each (the outer wrapper); user-supplied
    // BOOT.md instances of the same string are escaped to bracketed-safe variants.
    expect((message.match(/<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>/g) ?? []).length).toBe(1);
    expect((message.match(/<<<END_OPENCLAW_INTERNAL_CONTEXT>>>/g) ?? []).length).toBe(1);
    expect(message).toContain("[[OPENCLAW_INTERNAL_CONTEXT_BEGIN]]");
    expect(message).toContain("[[OPENCLAW_INTERNAL_CONTEXT_END]]");
  });

  it("returns failed when agent command throws", async () => {
    await withBootWorkspace({ bootContent: "Wake up and report." }, async (workspaceDir) => {
      agentCommand.mockRejectedValue(new Error("boom"));
      await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "failed",
        reason: "agent run failed: boom",
      });
      expect(agentCommand).toHaveBeenCalledTimes(1);
    });
  });

  it("uses per-agent session key when agentId is provided", async () => {
    const cfg = {};
    const agentId = "ops";
    const call = await runBootAndReturnCall({ cfg, agentId });
    const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
    expect(call.sessionKey).toBe(`agent:${resolveAgentIdFromSessionKey(mainSessionKey)}:boot`);
  });

  it("keeps boot session isolation when the main session key is configured", async () => {
    const cfg = { session: { mainKey: "primary" } };
    const agentId = "ops";
    const call = await runBootAndReturnCall({ cfg, agentId });
    const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
    expect(mainSessionKey).toBe("agent:ops:primary");
    expect(call.sessionKey).toBe("agent:ops:boot");
  });

  it("generates new session ID when no existing session exists", async () => {
    const content = "Say hello when you wake up.";
    await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
      agentCommand.mockResolvedValue(undefined);
      const cfg = {};
      await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "ran",
      });

      expect(agentCommand).toHaveBeenCalledTimes(1);
      const call = requireAgentCall();

      // Verify a boot-style session ID was generated (format: boot-YYYY-MM-DD_HH-MM-SS-xxx-xxxxxxxx)
      expect(call.sessionId).toMatch(
        /^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/,
      );
    });
  });

  it("uses a fresh boot session ID even when main session mapping already exists", async () => {
    const content = "Say hello when you wake up.";
    await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
      const cfg = {};
      const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg);
      const existingSessionId = "main-session-abc123";

      await saveSessionStore(storePath, {
        [sessionKey]: {
          sessionId: existingSessionId,
          updatedAt: Date.now(),
        },
      });

      agentCommand.mockResolvedValue(undefined);
      await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "ran",
      });

      expect(agentCommand).toHaveBeenCalledTimes(1);
      const call = requireAgentCall();

      expect(call.sessionId).not.toBe(existingSessionId);
      expect(call.sessionId).toMatch(
        /^boot-\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}-\d{3}-[0-9a-f]{8}$/,
      );
      expect(call.sessionKey).toBe(bootSessionKey);
      expectSessionMapping({ storePath, sessionKey, expectedSessionId: existingSessionId });
    });
  });

  it("does not mutate the original main session mapping after the boot run", async () => {
    const content = "Check if the system is healthy.";
    await withBootWorkspace({ bootContent: content }, async (workspaceDir) => {
      const cfg = {};
      const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg);
      const existingSessionId = "main-session-xyz789";

      await saveSessionStore(storePath, {
        [sessionKey]: {
          sessionId: existingSessionId,
          updatedAt: Date.now() - 60_000, // 1 minute ago
        },
      });

      mockAgentUpdatesRequestedSession(storePath);
      await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "ran",
      });

      expectSessionMapping({ storePath, sessionKey, expectedSessionId: existingSessionId });
      expectSessionMapping({ storePath, sessionKey: bootSessionKey });
    });
  });

  it("removes a boot-created boot-session mapping when none existed before", async () => {
    await withBootWorkspace({ bootContent: "health check" }, async (workspaceDir) => {
      const cfg = {};
      const { bootSessionKey, sessionKey, storePath } = resolveMainStore(cfg);

      mockAgentUpdatesRequestedSession(storePath);

      await expect(runBootOnce({ cfg, deps: makeDeps(), workspaceDir })).resolves.toEqual({
        status: "ran",
      });

      expectSessionMapping({ storePath, sessionKey });
      expectSessionMapping({ storePath, sessionKey: bootSessionKey });
    });
  });
});
