import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
import { captureEnv } from "../test-utils/env.js";
import { getFinishedSession, resetProcessRegistryForTests } from "./bash-process-registry.js";
import { createExecTool, createProcessTool } from "./bash-tools.js";
import { resolveShellFromPath, sanitizeBinaryOutput } from "./shell-utils.js";

const isWin = process.platform === "win32";
const defaultShell = isWin
  ? undefined
  : process.env.OPENCLAW_TEST_SHELL || resolveShellFromPath("bash") || process.env.SHELL || "sh";
// PowerShell: Start-Sleep for delays, ; for command separation, $null for null device
const shortDelayCmd = isWin ? "Start-Sleep -Milliseconds 4" : "sleep 0.004";
const yieldDelayCmd = isWin ? "Start-Sleep -Milliseconds 16" : "sleep 0.016";
const longDelayCmd = isWin ? "Start-Sleep -Milliseconds 72" : "sleep 0.072";
const POLL_INTERVAL_MS = 15;
const BACKGROUND_POLL_TIMEOUT_MS = isWin ? 8000 : 1200;
const NOTIFY_EVENT_TIMEOUT_MS = isWin ? 12_000 : 5_000;
const BACKGROUND_POLL_OPTIONS = {
  timeout: BACKGROUND_POLL_TIMEOUT_MS,
  interval: POLL_INTERVAL_MS,
};
const NOTIFY_POLL_OPTIONS = {
  timeout: NOTIFY_EVENT_TIMEOUT_MS,
  interval: POLL_INTERVAL_MS,
};
const SHELL_ENV_KEYS = ["SHELL"] as const;
const PATH_SHELL_ENV_KEYS = ["PATH", "SHELL"] as const;
const PROCESS_STATUS_RUNNING = "running";
const PROCESS_STATUS_COMPLETED = "completed";
const PROCESS_STATUS_FAILED = "failed";
const OUTPUT_DONE = "done";
const OUTPUT_NOPE = "nope";
const OUTPUT_EXEC_COMPLETED = "Exec completed";
const OUTPUT_EXIT_CODE_1 = "Command exited with code 1";
const shellEcho = (message: string) => (isWin ? `Write-Output ${message}` : `echo ${message}`);
const COMMAND_ECHO_HELLO = shellEcho("hello");
const COMMAND_PRINT_PATH = isWin ? "Write-Output $env:PATH" : "echo $PATH";
const COMMAND_EXIT_WITH_ERROR = "exit 1";
const SCOPE_KEY_ALPHA = "agent:alpha";
const SCOPE_KEY_BETA = "agent:beta";
const TEST_EXEC_DEFAULTS = { security: "full" as const, ask: "off" as const };
const DEFAULT_NOTIFY_SESSION_KEY = "agent:main:main";
const ECHO_HI_COMMAND = shellEcho("hi");
let callIdCounter = 0;
const nextCallId = () => `call${++callIdCounter}`;
type ExecToolInstance = ReturnType<typeof createExecTool>;
type ProcessToolInstance = ReturnType<typeof createProcessTool>;
type ExecToolArgs = Parameters<ExecToolInstance["execute"]>[1];
type ProcessToolArgs = Parameters<ProcessToolInstance["execute"]>[1];
type ExecToolConfig = Exclude<Parameters<typeof createExecTool>[0], undefined>;
type ExecToolRunOptions = Omit<ExecToolArgs, "command">;
type LabeledCase = { label: string };
const createTestExecTool = (
  defaults?: Parameters<typeof createExecTool>[0],
): ReturnType<typeof createExecTool> => createExecTool({ ...TEST_EXEC_DEFAULTS, ...defaults });
const createDisallowedElevatedExecTool = (
  defaultLevel: "off" | "on",
  overrides: Partial<ExecToolConfig> = {},
) =>
  createTestExecTool({
    elevated: { enabled: true, allowed: false, defaultLevel },
    ...overrides,
  });
const createNotifyOnExitExecTool = (overrides: Partial<ExecToolConfig> = {}) =>
  createTestExecTool({
    allowBackground: true,
    backgroundMs: 0,
    notifyOnExit: true,
    sessionKey: DEFAULT_NOTIFY_SESSION_KEY,
    ...overrides,
  });
const createScopedToolSet = (scopeKey: string) => ({
  exec: createTestExecTool({ backgroundMs: 10, scopeKey }),
  process: createProcessTool({ scopeKey }),
});
const execTool = createTestExecTool();
const processTool = createProcessTool();
const withLabel = <T extends object>(label: string, fields: T): T & LabeledCase => ({
  label,
  ...fields,
});
// Both PowerShell and bash use ; for command separation
const joinCommands = (commands: string[]) => commands.join("; ");
const echoAfterDelay = (message: string) => joinCommands([shortDelayCmd, shellEcho(message)]);
const echoLines = (lines: string[]) => joinCommands(lines.map((line) => shellEcho(line)));
const normalizeText = (value?: string) =>
  sanitizeBinaryOutput(value ?? "")
    .replace(/\r\n/g, "\n")
    .replace(/\r/g, "\n")
    .split("\n")
    .map((line) => line.replace(/\s+$/u, ""))
    .join("\n")
    .trim();
type ToolTextContent = Array<{ type: string; text?: string }>;
const readTextContent = (content: ToolTextContent) =>
  content.find((part) => part.type === "text")?.text;
const readNormalizedTextContent = (content: ToolTextContent) =>
  normalizeText(readTextContent(content));
const readTrimmedLines = (content: ToolTextContent) =>
  (readTextContent(content) ?? "").split("\n").map((line) => line.trim());
const readTotalLines = (details: unknown) => (details as { totalLines?: number }).totalLines;
const readProcessStatus = (details: unknown) => (details as { status?: string }).status;
const readProcessStatusOrRunning = (details: unknown) =>
  readProcessStatus(details) ?? PROCESS_STATUS_RUNNING;
const expectTextContainsValues = (
  text: string,
  values: string[] | undefined,
  shouldContain: boolean,
) => {
  if (!values) {
    return;
  }
  for (const value of values) {
    if (shouldContain) {
      expect(text).toContain(value);
    } else {
      expect(text).not.toContain(value);
    }
  }
};
type ProcessSessionSummary = { sessionId: string; name?: string };
const hasSession = (sessions: ProcessSessionSummary[], sessionId: string) =>
  sessions.some((session) => session.sessionId === sessionId);
const executeExecTool = (tool: ExecToolInstance, params: ExecToolArgs) =>
  tool.execute(nextCallId(), params);
const executeExecCommand = (
  tool: ExecToolInstance,
  command: string,
  options: ExecToolRunOptions = {},
) => executeExecTool(tool, { command, ...options });
const executeProcessTool = (tool: ProcessToolInstance, params: ProcessToolArgs) =>
  tool.execute(nextCallId(), params);
type ProcessPollResult = { status: string; output?: string };
async function listProcessSessions(tool: ProcessToolInstance) {
  const list = await executeProcessTool(tool, { action: "list" });
  return (list.details as { sessions: ProcessSessionSummary[] }).sessions;
}
async function pollProcessSession(params: {
  tool: ProcessToolInstance;
  sessionId: string;
}): Promise<ProcessPollResult> {
  const poll = await executeProcessTool(params.tool, {
    action: "poll",
    sessionId: params.sessionId,
  });
  return {
    status: readProcessStatusOrRunning(poll.details),
    output: readTextContent(poll.content),
  };
}
function applyDefaultShellEnv() {
  if (!isWin && defaultShell) {
    process.env.SHELL = defaultShell;
  }
}

function useCapturedEnv(keys: string[], afterCapture?: () => void) {
  let envSnapshot: ReturnType<typeof captureEnv>;

  beforeEach(() => {
    envSnapshot = captureEnv(keys);
    afterCapture?.();
  });

  afterEach(() => {
    envSnapshot.restore();
  });
}

async function waitForCompletion(sessionId: string) {
  let status = PROCESS_STATUS_RUNNING;
  await expect
    .poll(async () => {
      status = (await pollProcessSession({ tool: processTool, sessionId })).status;
      return status;
    }, BACKGROUND_POLL_OPTIONS)
    .not.toBe(PROCESS_STATUS_RUNNING);
  return status;
}

function requireSessionId(details: { sessionId?: string }): string {
  if (!details.sessionId) {
    throw new Error("expected sessionId in exec result details");
  }
  return details.sessionId;
}
const requireRunningSessionId = (result: { details: unknown }) => {
  expect(readProcessStatus(result.details)).toBe(PROCESS_STATUS_RUNNING);
  return requireSessionId(result.details as { sessionId?: string });
};

function hasNotifyEventForPrefix(prefix: string): boolean {
  return peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY).some((event) => event.includes(prefix));
}

async function waitForNotifyEvent(sessionId: string) {
  const prefix = sessionId.slice(0, 8);
  let finished = getFinishedSession(sessionId);
  let hasEvent = hasNotifyEventForPrefix(prefix);
  await expect
    .poll(() => {
      finished = getFinishedSession(sessionId);
      hasEvent = hasNotifyEventForPrefix(prefix);
      return Boolean(finished && hasEvent);
    }, NOTIFY_POLL_OPTIONS)
    .toBe(true);
  return {
    finished: finished ?? getFinishedSession(sessionId),
    hasEvent: hasEvent || hasNotifyEventForPrefix(prefix),
  };
}

async function startBackgroundCommand(tool: ExecToolInstance, command: string) {
  const result = await executeExecCommand(tool, command, { background: true });
  return requireRunningSessionId(result);
}
async function runBackgroundCommandToCompletion(tool: ExecToolInstance, command: string) {
  const sessionId = await startBackgroundCommand(tool, command);
  const status = await waitForCompletion(sessionId);
  return { sessionId, status };
}

type ProcessLogWindow = { offset?: number; limit?: number };
async function readProcessLog(sessionId: string, options: ProcessLogWindow = {}) {
  return executeProcessTool(processTool, {
    action: "log",
    sessionId,
    ...options,
  });
}

const LONG_LOG_LINE_COUNT = 201;
type LongLogExpectationCase = LabeledCase & {
  options?: ProcessLogWindow;
  firstLine: string;
  lastLine?: string;
  mustContain?: string[];
  mustNotContain?: string[];
};
type ShortLogExpectationCase = LabeledCase & {
  lines: string[];
  options: ProcessLogWindow;
  expectedText: string;
  expectedTotalLines: number;
};
type ProcessLogSnapshot = {
  text: string;
  normalizedText: string;
  lines: string[];
  totalLines: number | undefined;
};
const EXPECTED_TOTAL_LINES_THREE = 3;
type DisallowedElevationCase = LabeledCase & {
  defaultLevel: "off" | "on";
  overrides?: Partial<ExecToolConfig>;
  requestElevated?: boolean;
  expectedError?: string;
  expectedOutputIncludes?: string;
};
type NotifyNoopCase = LabeledCase & {
  notifyOnExitEmptySuccess: boolean;
};
const NOOP_NOTIFY_CASES: NotifyNoopCase[] = [
  withLabel("default behavior skips no-op completion events", { notifyOnExitEmptySuccess: false }),
  withLabel("explicitly enabling no-op completion emits completion events", {
    notifyOnExitEmptySuccess: true,
  }),
];
const DISALLOWED_ELEVATION_CASES: DisallowedElevationCase[] = [
  withLabel("rejects elevated requests when not allowed", {
    defaultLevel: "off",
    overrides: {
      messageProvider: "telegram",
      sessionKey: DEFAULT_NOTIFY_SESSION_KEY,
    },
    requestElevated: true,
    expectedError: "Context: provider=telegram session=agent:main:main",
  }),
  withLabel("does not default to elevated when not allowed", {
    defaultLevel: "on",
    overrides: {
      backgroundMs: 1000,
      timeoutSec: 5,
    },
    expectedOutputIncludes: "hi",
  }),
];
const SHORT_LOG_EXPECTATION_CASES: ShortLogExpectationCase[] = [
  withLabel("logs line-based slices and defaults to last lines", {
    lines: ["one", "two", "three"],
    options: { limit: 2 },
    expectedText: "two\nthree",
    expectedTotalLines: EXPECTED_TOTAL_LINES_THREE,
  }),
  withLabel("supports line offsets for log slices", {
    lines: ["alpha", "beta", "gamma"],
    options: { offset: 1, limit: 1 },
    expectedText: "beta",
    expectedTotalLines: EXPECTED_TOTAL_LINES_THREE,
  }),
];
const LONG_LOG_EXPECTATION_CASES: LongLogExpectationCase[] = [
  withLabel("applies default tail only when no explicit log window is provided", {
    firstLine: "line-2",
    mustContain: ["showing last 200 of 201 lines", "line-2", "line-201"],
  }),
  withLabel("keeps offset-only log requests unbounded by default tail mode", {
    options: { offset: 30 },
    firstLine: "line-31",
    lastLine: "line-201",
    mustNotContain: ["showing last 200"],
  }),
];
const expectNotifyNoopEvents = (
  events: string[],
  notifyOnExitEmptySuccess: boolean,
  label: string,
) => {
  if (!notifyOnExitEmptySuccess) {
    expect(events, label).toEqual([]);
    return;
  }
  expect(events.length, label).toBeGreaterThan(0);
  expect(
    events.some((event) => event.includes(OUTPUT_EXEC_COMPLETED)),
    label,
  ).toBe(true);
};
const runDisallowedElevationCase = async ({
  defaultLevel,
  overrides,
  requestElevated,
  expectedError,
  expectedOutputIncludes,
}: DisallowedElevationCase) => {
  const customBash = createDisallowedElevatedExecTool(defaultLevel, overrides);
  if (expectedError) {
    await expect(
      executeExecCommand(customBash, ECHO_HI_COMMAND, { elevated: requestElevated }),
    ).rejects.toThrow(expectedError);
    return;
  }

  const result = await executeExecCommand(customBash, ECHO_HI_COMMAND);
  if (expectedOutputIncludes === undefined) {
    throw new Error("expected text assertion value");
  }
  expect(readTextContent(result.content) ?? "").toContain(expectedOutputIncludes);
};
const runShortLogExpectationCase = async ({
  lines,
  options,
  expectedText,
  expectedTotalLines,
}: ShortLogExpectationCase) => {
  const snapshot = await readBackgroundLogSnapshot(lines, options);
  expect(snapshot.normalizedText).toBe(expectedText);
  expect(snapshot.totalLines).toBe(expectedTotalLines);
};
const readBackgroundLogSnapshot = async (
  lines: string[],
  options: ProcessLogWindow = {},
): Promise<ProcessLogSnapshot> => {
  const { sessionId } = await runBackgroundCommandToCompletion(execTool, echoLines(lines));
  const log = await readProcessLog(sessionId, options);
  return {
    text: readTextContent(log.content) ?? "",
    normalizedText: readNormalizedTextContent(log.content),
    lines: readTrimmedLines(log.content),
    totalLines: readTotalLines(log.details),
  };
};
const runLongLogExpectationCase = async ({
  options,
  firstLine,
  lastLine,
  mustContain,
  mustNotContain,
}: LongLogExpectationCase) => {
  const snapshot = await readBackgroundLogSnapshot(
    Array.from({ length: LONG_LOG_LINE_COUNT }, (_value, index) => `line-${index + 1}`),
    options,
  );
  expect(snapshot.lines[0]).toBe(firstLine);
  if (lastLine) {
    expect(snapshot.lines[snapshot.lines.length - 1]).toBe(lastLine);
  }
  expect(snapshot.totalLines).toBe(LONG_LOG_LINE_COUNT);
  expectTextContainsValues(snapshot.text, mustContain, true);
  expectTextContainsValues(snapshot.text, mustNotContain, false);
};
const runNotifyNoopCase = async ({ label, notifyOnExitEmptySuccess }: NotifyNoopCase) => {
  const tool = createNotifyOnExitExecTool(
    notifyOnExitEmptySuccess ? { notifyOnExitEmptySuccess: true } : {},
  );

  const { status } = await runBackgroundCommandToCompletion(tool, shortDelayCmd);
  expect(status).toBe(PROCESS_STATUS_COMPLETED);
  const events = peekSystemEvents(DEFAULT_NOTIFY_SESSION_KEY);
  expectNotifyNoopEvents(events, notifyOnExitEmptySuccess, label);
};

beforeEach(() => {
  callIdCounter = 0;
  resetProcessRegistryForTests();
  resetSystemEventsForTest();
});

describe("exec tool backgrounding", () => {
  useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv);

  it(
    "backgrounds after yield and can be polled",
    async () => {
      const result = await executeExecCommand(
        execTool,
        joinCommands([yieldDelayCmd, shellEcho(OUTPUT_DONE)]),
        { yieldMs: 10 },
      );

      // Timing can race here: command may already be complete before the first response.
      if (result.details.status === PROCESS_STATUS_COMPLETED) {
        expect(readTextContent(result.content) ?? "").toContain(OUTPUT_DONE);
        return;
      }

      const sessionId = requireRunningSessionId(result);

      let output = "";
      await expect
        .poll(async () => {
          const pollResult = await pollProcessSession({ tool: processTool, sessionId });
          output = pollResult.output ?? "";
          return pollResult.status;
        }, BACKGROUND_POLL_OPTIONS)
        .toBe(PROCESS_STATUS_COMPLETED);

      expect(output).toContain(OUTPUT_DONE);
    },
    isWin ? 15_000 : 5_000,
  );

  it("supports explicit background and derives session name from the command", async () => {
    const sessionId = await startBackgroundCommand(execTool, COMMAND_ECHO_HELLO);

    const sessions = await listProcessSessions(processTool);
    expect(hasSession(sessions, sessionId)).toBe(true);
    expect(sessions.find((s) => s.sessionId === sessionId)?.name).toBe(COMMAND_ECHO_HELLO);
  });

  it("uses default timeout when timeout is omitted", async () => {
    const customBash = createTestExecTool({
      timeoutSec: 0.05,
      backgroundMs: 10,
      allowBackground: false,
    });
    await expect(executeExecCommand(customBash, longDelayCmd)).rejects.toThrow(/timed out/i);
  });

  it.each<DisallowedElevationCase>(DISALLOWED_ELEVATION_CASES)(
    "$label",
    runDisallowedElevationCase,
  );

  it.each<ShortLogExpectationCase>(SHORT_LOG_EXPECTATION_CASES)(
    "$label",
    runShortLogExpectationCase,
  );

  it.each<LongLogExpectationCase>(LONG_LOG_EXPECTATION_CASES)("$label", runLongLogExpectationCase);
  it("scopes process sessions by scopeKey", async () => {
    const alphaTools = createScopedToolSet(SCOPE_KEY_ALPHA);
    const betaTools = createScopedToolSet(SCOPE_KEY_BETA);

    const sessionA = await startBackgroundCommand(alphaTools.exec, shortDelayCmd);
    const sessionB = await startBackgroundCommand(betaTools.exec, shortDelayCmd);

    const sessionsA = await listProcessSessions(alphaTools.process);
    expect(hasSession(sessionsA, sessionA)).toBe(true);
    expect(hasSession(sessionsA, sessionB)).toBe(false);

    const pollB = await pollProcessSession({
      tool: betaTools.process,
      sessionId: sessionA,
    });
    expect(pollB.status).toBe(PROCESS_STATUS_FAILED);
  });
});

describe("exec exit codes", () => {
  useCapturedEnv([...SHELL_ENV_KEYS], applyDefaultShellEnv);

  it("treats non-zero exits as completed and appends exit code", async () => {
    const command = joinCommands([shellEcho(OUTPUT_NOPE), COMMAND_EXIT_WITH_ERROR]);
    const result = await executeExecCommand(execTool, command);
    const resultDetails = result.details as { status?: string; exitCode?: number | null };
    expect(readProcessStatus(resultDetails)).toBe(PROCESS_STATUS_COMPLETED);
    expect(resultDetails.exitCode).toBe(1);

    const text = readNormalizedTextContent(result.content);
    expect(text).toContain(OUTPUT_NOPE);
    expect(text).toContain(OUTPUT_EXIT_CODE_1);
  });
});

describe("exec notifyOnExit", () => {
  it("enqueues a system event when a backgrounded exec exits", async () => {
    const tool = createNotifyOnExitExecTool();

    const sessionId = await startBackgroundCommand(tool, echoAfterDelay("notify"));

    const { finished, hasEvent } = await waitForNotifyEvent(sessionId);

    expect(finished).toBeTruthy();
    expect(hasEvent).toBe(true);
  });

  it.each<NotifyNoopCase>(NOOP_NOTIFY_CASES)("$label", runNotifyNoopCase);
});

describe("exec PATH handling", () => {
  useCapturedEnv([...PATH_SHELL_ENV_KEYS], applyDefaultShellEnv);

  it("prepends configured path entries", async () => {
    const basePath = isWin ? "C:\\Windows\\System32" : "/usr/bin";
    const prepend = isWin ? ["C:\\custom\\bin", "C:\\oss\\bin"] : ["/custom/bin", "/opt/oss/bin"];
    process.env.PATH = basePath;

    const tool = createTestExecTool({ pathPrepend: prepend });
    const result = await executeExecCommand(tool, COMMAND_PRINT_PATH);

    const text = readNormalizedTextContent(result.content);
    const entries = text.split(path.delimiter);
    const prependIndexes = prepend.map((entry) => entries.indexOf(entry));
    for (const index of prependIndexes) {
      expect(index).toBeGreaterThanOrEqual(0);
    }
    for (let i = 1; i < prependIndexes.length; i += 1) {
      expect(prependIndexes[i]).toBeGreaterThan(prependIndexes[i - 1]);
    }
    const baseIndex = entries.indexOf(basePath);
    expect(baseIndex).toBeGreaterThanOrEqual(0);
    for (const index of prependIndexes) {
      expect(index).toBeLessThan(baseIndex);
    }
  });
});
