import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import {
  GatewayWsClient,
  normalizeAssistantUserId,
  normalizeCommandCode,
  enabledAgentIdsFromAccounts,
  resolveSkillInstallTarget,
  parseAssistantWorkspaceAgentId,
  readCronJobsByAgentId,
  findInstalledExpertSkillsRoot,
  shouldSyncBuiltInTemplate,
  resolveGatewaySkillTarget,
  isGatewaySkillCommand,
} from "./ws-client.ts";

async function handleClientMessage(
  client: GatewayWsClient,
  message: Record<string, unknown>,
): Promise<void> {
  await (client as unknown as { handleMessage(value: Record<string, unknown>): Promise<void> }).handleMessage(message);
}

function createReplyingClient(gatewayId: string): { client: GatewayWsClient; replies: Record<string, unknown>[] } {
  const replies: Record<string, unknown>[] = [];
  const client = new GatewayWsClient({
    serverUrl: "ws://localhost:1",
    gatewayId,
    updater: {} as never,
  });
  const socket = {
    readyState: 1,
    send(payload: string, callback?: (error?: Error) => void) {
      replies.push(JSON.parse(payload) as Record<string, unknown>);
      callback?.();
    },
  };
  Object.assign(client as object, { ws: socket });
  return { client, replies };
}

describe("LIST_FREE_SKILLS", () => {
  it("通过 action 分发扫描当前 workspace，并返回来源明确的路径字段", async () => {
    const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "free-skill-ws-client-"));
    try {
      const skillPath = path.join(workspace, "skills", "demo");
      fs.mkdirSync(skillPath, { recursive: true });
      fs.writeFileSync(path.join(skillPath, ".meta.json"), JSON.stringify({source: "xg-free"}));
      fs.writeFileSync(
        path.join(skillPath, "SKILL.md"),
        "---\nname: Demo\ndescription: local demo\nversion: 1.2.3\n---\n",
      );

      const { client, replies } = createReplyingClient("gateway-local-1");
      Object.assign(client as object, {
        resolveRegularSkillContext: async () => ({
          skillsDir: path.join(workspace, "skills"),
          localAgentId: "sales-agent",
          workspace,
        }),
      });
      await handleClientMessage(client, {
        action: "LIST_FREE_SKILLS",
        userId: "12345",
        agentId: "injected-agent-must-be-ignored",
        workspace: path.join(workspace, "..", "external-workspace"),
        replyId: "free-1",
      });

      const hostSkillFilePath = fs.realpathSync(path.join(skillPath, "SKILL.md"));
      assert.deepEqual(replies, [{
        type: "REPLY",
        replyId: "free-1",
        success: true,
        action: "LIST_FREE_SKILLS",
        data: {
          gatewayId: "gateway-local-1",
          agentId: "sales-agent",
          directoryPath: fs.realpathSync(path.join(workspace, "skills")),
          skills: [{
            code: "demo",
            name: "Demo",
            description: "local demo",
            version: "1.2.3",
            workspaceRelativePath: path.join("skills", "demo"),
            skillFilePath: path.join("skills", "demo", "SKILL.md"),
            hostSkillFilePath,
          }],
        },
      }]);
    } finally {
      fs.rmSync(workspace, { recursive: true, force: true });
    }
  });

  it("忽略请求注入的外部 workspace 和 agentId，只使用解析出的 Agent workspace", async () => {
    const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "free-skill-resolved-ws-"));
    const externalWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), "free-skill-injected-ws-"));
    try {
      const safeSkillPath = path.join(workspace, "skills", "safe");
      fs.mkdirSync(safeSkillPath, { recursive: true });
      fs.writeFileSync(path.join(safeSkillPath, ".meta.json"), JSON.stringify({source: "xg-free"}));
      fs.writeFileSync(path.join(safeSkillPath, "SKILL.md"), "---\nname: Safe\n---\n");

      const externalSkillPath = path.join(externalWorkspace, "skills", "outside");
      fs.mkdirSync(externalSkillPath, { recursive: true });
      fs.writeFileSync(path.join(externalSkillPath, ".meta.json"), JSON.stringify({source: "xg-free"}));
      fs.writeFileSync(path.join(externalSkillPath, "SKILL.md"), "---\nname: Outside\n---\n");

      const { client, replies } = createReplyingClient("gateway-local-2");
      Object.assign(client as object, {
        resolveRegularSkillContext: async () => ({
          skillsDir: path.join(workspace, "skills"),
          localAgentId: "resolved-agent",
          workspace,
        }),
      });
      await handleClientMessage(client, {
        action: "LIST_FREE_SKILLS",
        userId: "12345",
        agentId: "injected-agent",
        workspace: externalWorkspace,
        workspacePath: externalWorkspace,
        agentWorkspace: externalWorkspace,
        replyId: "free-isolated",
      });

      const response = replies[0] as { data: Record<string, unknown> };
      assert.equal(response.data.agentId, "resolved-agent");
      assert.equal(
        response.data.directoryPath,
        fs.realpathSync(path.join(workspace, "skills")),
      );
      assert.deepEqual(response.data.skills, [{
        code: "safe",
        name: "Safe",
        description: "",
        version: "",
        workspaceRelativePath: path.join("skills", "safe"),
        skillFilePath: path.join("skills", "safe", "SKILL.md"),
        hostSkillFilePath: fs.realpathSync(path.join(safeSkillPath, "SKILL.md")),
      }]);
    } finally {
      fs.rmSync(workspace, { recursive: true, force: true });
      fs.rmSync(externalWorkspace, { recursive: true, force: true });
    }
  });

  it("缺少 userId 时返回明确错误，不静默丢弃新 action", async () => {
    const { client, replies } = createReplyingClient("gateway-local-1");

    await handleClientMessage(client, {
      action: "LIST_FREE_SKILLS",
      replyId: "free-missing-user",
    });

    assert.deepEqual(replies, [{
      type: "REPLY",
      replyId: "free-missing-user",
      success: false,
      error: "INVALID_REQUEST",
      message: "Missing userId parameter",
      action: "LIST_FREE_SKILLS",
    }]);
  });
});

describe("command code boundary", () => {
  it("只接受单一路径段，不静默改写路径穿越输入", () => {
    assert.equal(normalizeCommandCode("demo-skill"), "demo-skill");
    assert.equal(normalizeCommandCode("../demo"), undefined);
    assert.equal(normalizeCommandCode("a/b"), undefined);
    assert.equal(normalizeCommandCode("a\\b"), undefined);
    assert.equal(normalizeCommandCode("."), undefined);
    assert.equal(normalizeCommandCode(".."), undefined);
    assert.equal(normalizeCommandCode(""), undefined);
    assert.equal(normalizeCommandCode(123), undefined);
  });
});

describe("gateway heartbeat agent accounts", () => {
  it("reports enabled accounts and accounts without an explicit enabled field", () => {
    const agentIds = enabledAgentIdsFromAccounts({
      channels: {
        xg_cwork_im: {
          accounts: {
            "assistant-enabled": { agentId: "assistant-enabled", enabled: true },
            "assistant-legacy": { agentId: "assistant-legacy" },
            default: { groupPolicy: "mention" },
          },
        },
      },
    });

    assert.deepEqual(agentIds, ["assistant-enabled", "assistant-legacy"]);
  });

  it("filters accounts explicitly disabled in openclaw.json", () => {
    const agentIds = enabledAgentIdsFromAccounts({
      channels: {
        xg_cwork_im: {
          accounts: {
            "assistant-1514822133731725314": {
              agentId: "assistant-1514822133731725314",
              enabled: false,
              name: "个人助理",
              robotKey: "secret-is-not-used-for-heartbeats",
            },
            "assistant-active": { agentId: "assistant-active", enabled: true },
          },
        },
      },
    });

    assert.deepEqual(agentIds, ["assistant-active"]);
  });

  it("returns no agents when the account path is absent or malformed", () => {
    assert.deepEqual(enabledAgentIdsFromAccounts({}), []);
    assert.deepEqual(enabledAgentIdsFromAccounts({ channels: { xg_cwork_im: { accounts: [] } } }), []);
  });
});

describe("普通 Skill 配置驱动的安装目录", () => {
  const config = {
    channels: {
      xg_cwork_im: {
        accounts: {
          "assistant-12345": { agentId: "assistant-12345", enabled: true },
          "assistant-67890": { agentId: "assistant-67890", enabled: false },
        },
      },
    },
    bindings: [{
      agentId: "sales-agent",
      match: { channel: "xg_cwork_im", accountId: "assistant-12345" },
    }],
    agents: {
      defaults: { workspace: "/var/openclaw/default" },
      list: [{ id: "sales-agent", workspace: "/var/openclaw/sales" }],
    },
  };

  it("通过 account、binding 和 agent workspace 解析目录", () => {
    assert.deepEqual(resolveSkillInstallTarget(config, "assistant-12345"), {
      localAgentId: "sales-agent",
      skillsDir: path.join("/var/openclaw/sales", "skills"),
      workspace: "/var/openclaw/sales",
    });
  });

  it("通过 OpenClaw 8.1 agents.entries 解析 workspace", () => {
    assert.deepEqual(resolveSkillInstallTarget({
      ...config,
      agents: {
        defaults: { workspace: "/var/openclaw/default" },
        entries: {
          "sales-agent": { workspace: "/var/openclaw/v8-sales" },
        },
      },
    }, "assistant-12345"), {
      localAgentId: "sales-agent",
      skillsDir: path.join("/var/openclaw/v8-sales", "skills"),
      workspace: "/var/openclaw/v8-sales",
    });
  });

  it("8.1 Agent 未单独配置 workspace 时继承 agents.defaults.workspace", () => {
    assert.deepEqual(resolveSkillInstallTarget({
      ...config,
      agents: {
        defaults: { workspace: "/var/openclaw/default" },
        entries: { "sales-agent": { name: "Sales" } },
      },
    }, "assistant-12345"), {
      localAgentId: "sales-agent",
      skillsDir: path.join("/var/openclaw/default", "skills"),
      workspace: "/var/openclaw/default",
    });
  });

  it("拒绝显式 disabled 的 account", () => {
    assert.throws(() => resolveSkillInstallTarget(config, "assistant-67890"), /已禁用/);
  });

  it("找不到 account、binding 后的 agent 或 workspace 时拒绝回退到命名目录", () => {
    assert.throws(() => resolveSkillInstallTarget(config, "assistant-99999"), /未找到 userId/);
    assert.throws(() => resolveSkillInstallTarget({
      ...config,
      bindings: [{ agentId: "missing-agent", match: { channel: "xg_cwork_im", accountId: "assistant-12345" } }],
    }, "assistant-12345"), /未找到本地 Agent/);
    assert.throws(() => resolveSkillInstallTarget({
      ...config,
      agents: { list: [{ id: "sales-agent" }] },
    }, "assistant-12345"), /未配置 workspace/);
  });
});

describe("assistant workspace naming", () => {
  it("accepts workspace-assistant-* only when suffix is at least 5 digits", () => {
    assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-12345"), "assistant-12345");
    assert.equal(
      parseAssistantWorkspaceAgentId("workspace-assistant-12342325232333223223"),
      "assistant-12342325232333223223"
    );
    assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-000001"), "assistant-000001");
  });

  it("rejects short, non-numeric, and malformed workspace names", () => {
    assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-1234"), undefined);
    assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-1234a"), undefined);
    assert.equal(parseAssistantWorkspaceAgentId("workspace-assistant-abcde"), undefined);
    assert.equal(parseAssistantWorkspaceAgentId("workspace-coder-12345"), undefined);
  });

  it("normalizes command userId using the same numeric suffix rule", () => {
    assert.equal(normalizeAssistantUserId("12345"), "12345");
    assert.equal(normalizeAssistantUserId("assistant-12345"), "12345");
    assert.equal(normalizeAssistantUserId("assistant-000001"), "000001");
  });

  it("rejects unsafe or non-conforming command userId values", () => {
    assert.equal(normalizeAssistantUserId("1234"), undefined);
    assert.equal(normalizeAssistantUserId("assistant-1234"), undefined);
    assert.equal(normalizeAssistantUserId("assistant-1234a"), undefined);
    assert.equal(normalizeAssistantUserId("../assistant-12345"), undefined);
  });
});

describe("built-in template update boundary", () => {
  it("only syncs the template for an explicit built-in UPDATE_SKILL command", () => {
    assert.equal(shouldSyncBuiltInTemplate("UPDATE_SKILL", true), true);
    assert.equal(shouldSyncBuiltInTemplate("UPDATE_SKILL", false), false);
    assert.equal(shouldSyncBuiltInTemplate("UPDATE_SKILL", undefined), false);
    assert.equal(shouldSyncBuiltInTemplate("INSTALL_SKILL", true), false);
    assert.equal(shouldSyncBuiltInTemplate("UNINSTALL_SKILL", true), false);
  });
});

describe("Gateway 公共 Skill 安装边界", () => {
  it("固定安装到 OpenClaw 顶层 skills，不接受指令传入任意目录", () => {
    assert.equal(resolveGatewaySkillTarget("/var/openclaw"), path.join("/var/openclaw", "skills"));
  });

  it("仅允许 Skill 安装、更新和卸载使用 gateway 作用域", () => {
    assert.equal(isGatewaySkillCommand("INSTALL_SKILL", "gateway"), true);
    assert.equal(isGatewaySkillCommand("UPDATE_SKILL", "gateway"), true);
    assert.equal(isGatewaySkillCommand("UNINSTALL_SKILL", "gateway"), true);
    assert.equal(isGatewaySkillCommand("LIST_SKILLS", "gateway"), false);
    assert.equal(isGatewaySkillCommand("UPDATE_SKILL", "agent"), false);
  });
});

describe("用户创建 Skill 的平台命令冲突保护", () => {
  it("Agent 和 Gateway 的平台命令在目录寻址前拒绝用户命名空间", async () => {
    for (const installScope of ["agent", "gateway"]) {
      for (const action of ["INSTALL_SKILL", "UPDATE_SKILL", "UNINSTALL_SKILL"]) {
        const { client, replies } = createReplyingClient(`namespace-${installScope}-${action}`);
        Object.assign(client as object, {
          resolveRegularSkillTarget: async () => { throw new Error("不应寻找目标"); },
          resolveRegularSkillContext: async () => { throw new Error("不应寻找目标"); },
        });
        await handleClientMessage(client, { type: "COMMAND", action, installScope,
          userId: "assistant-12345", code: "xg_userscope_demo", replyId: "namespace" });
        assert.equal(replies.length, 1);
        assert.equal(replies[0].success, false);
        assert.match(String(replies[0].message), /用户自建命名空间/);
      }
    }
  });

  it("INSTALL/UPDATE/UNINSTALL 遇到 xg-free 同名目录时均拒绝且不改盘", async () => {
    for (const action of ["INSTALL_SKILL", "UPDATE_SKILL", "UNINSTALL_SKILL"] as const) {
      const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-free-skill-conflict-"));
      try {
        const targetDir = path.join(workspace, "skills");
        const skillDir = path.join(targetDir, "demo");
        fs.mkdirSync(skillDir, { recursive: true });
        fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: Demo\n---\n# Demo\n");
        fs.writeFileSync(path.join(skillDir, ".meta.json"), JSON.stringify({ source: "xg-free" }));

        let installCalls = 0;
        const commandClient = new GatewayWsClient({
          serverUrl: "ws://localhost:1",
          gatewayId: `gateway-conflict-${action}`,
          updater: {
            manualInstall: async () => {
              installCalls += 1;
              return { success: true, message: "unexpected" };
            },
          } as never,
        } as never);
        // Keep the test fixture independent from a host OpenClaw config.
        const socketReplies: Record<string, unknown>[] = [];
        Object.assign(commandClient as object, {
          ws: {
            readyState: 1,
            send(payload: string, callback?: (error?: Error) => void) {
              socketReplies.push(JSON.parse(payload) as Record<string, unknown>);
              callback?.();
            },
          },
          resolveRegularSkillTarget: async () => targetDir,
          resolveRegularSkillContext: async () => ({
            skillsDir: targetDir,
            workspace,
            localAgentId: "assistant-12345",
          }),
        });

        const originalRandom = Math.random;
        if (action === "UPDATE_SKILL") Math.random = () => 0;
        try {
          await handleClientMessage(commandClient, {
            action,
            userId: "assistant-12345",
            code: "demo",
            replyId: `conflict-${action}`,
          });
          if (action === "UPDATE_SKILL") await new Promise((resolve) => setTimeout(resolve, 20));
        } finally {
          Math.random = originalRandom;
        }

        assert.equal(installCalls, 0, `${action} 不应调用 updater`);
        assert.equal(socketReplies.length, 1);
        assert.equal(socketReplies[0].success, false);
        assert.match(String(socketReplies[0].message), /用户创建的 Agent Skill/);
        assert.equal(fs.existsSync(skillDir), true, `${action} 不应删除或替换用户目录`);
      } finally {
        fs.rmSync(workspace, { recursive: true, force: true });
      }
    }
  });
});

describe("expert skill update target", () => {
  it("从配置解析的 workspace 返回 .user/skills 父目录", async () => {
    const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-expert-skill-"));
    try {
      const skillsRoot = path.join(workspace, ".user", "skills");
      fs.mkdirSync(path.join(skillsRoot, "demo"), { recursive: true });

      assert.equal(await findInstalledExpertSkillsRoot(workspace, "demo"), skillsRoot);
      assert.equal(await findInstalledExpertSkillsRoot(workspace, "missing"), undefined);
    } finally {
      fs.rmSync(workspace, { recursive: true, force: true });
    }
  });

  it("专家列表使用配置解析出的自定义 workspace", async () => {
    const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-custom-agent-workspace-"));
    try {
      const expertDir = path.join(workspace, ".user", "experts", "demo");
      fs.mkdirSync(expertDir, { recursive: true });
      fs.writeFileSync(path.join(expertDir, ".meta.json"), JSON.stringify({
        code: "demo",
        name: "Demo Expert",
        version: "2.0.0",
      }));

      const { client, replies } = createReplyingClient("gateway-local-expert");
      Object.assign(client as object, {
        resolveRegularSkillContext: async () => ({
          skillsDir: path.join(workspace, "skills"),
          localAgentId: "assistant-12345",
          workspace,
        }),
      });

      await handleClientMessage(client, {
        action: "LIST_EXPERTS",
        userId: "assistant-12345",
        replyId: "expert-list",
      });

      assert.deepEqual(replies, [{
        type: "REPLY",
        replyId: "expert-list",
        success: true,
        action: "LIST_EXPERTS",
        data: [{ code: "demo", name: "Demo Expert", version: "2.0.0" }],
      }]);
    } finally {
      fs.rmSync(workspace, { recursive: true, force: true });
    }
  });
});

describe("cron job sqlite lookup", () => {
  it("returns all cron_jobs rows for the requested agent_id", () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cron-jobs-"));
    const dbPath = path.join(dir, "openclaw.sqlite");
    const db = new DatabaseSync(dbPath);
    try {
      db.exec(`
        CREATE TABLE cron_jobs (
          id INTEGER PRIMARY KEY,
          agent_id TEXT NOT NULL,
          name TEXT NOT NULL,
          cron TEXT NOT NULL
        );
      `);
      db.prepare("INSERT INTO cron_jobs (agent_id, name, cron) VALUES (?, ?, ?)").run(
        "assistant-1579723293989240833",
        "morning",
        "0 9 * * *"
      );
      db.prepare("INSERT INTO cron_jobs (agent_id, name, cron) VALUES (?, ?, ?)").run(
        "assistant-1579723293989240833",
        "evening",
        "0 18 * * *"
      );
      db.prepare("INSERT INTO cron_jobs (agent_id, name, cron) VALUES (?, ?, ?)").run(
        "assistant-000001",
        "other",
        "0 12 * * *"
      );
    } finally {
      db.close();
    }

    const rows = readCronJobsByAgentId("assistant-1579723293989240833", dbPath);

    assert.equal(rows.length, 2);
    assert.deepEqual(
      rows.map((row) => row.name),
      ["morning", "evening"]
    );
  });

  it("returns an empty list when no cron_jobs row matches", () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cron-jobs-"));
    const dbPath = path.join(dir, "openclaw.sqlite");
    const db = new DatabaseSync(dbPath);
    try {
      db.exec("CREATE TABLE cron_jobs (id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL);");
    } finally {
      db.close();
    }

    assert.deepEqual(readCronJobsByAgentId("assistant-1579723293989240833", dbPath), []);
  });

  it("returns an empty list when the sqlite file is not readable", () => {
    const missingPath = path.join(os.tmpdir(), `missing-openclaw-${Date.now()}.sqlite`);

    assert.deepEqual(readCronJobsByAgentId("assistant-1579723293989240833", missingPath), []);
  });

  it("returns an empty list when cron_jobs table does not exist", () => {
    const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cron-jobs-"));
    const dbPath = path.join(dir, "openclaw.sqlite");
    const db = new DatabaseSync(dbPath);
    try {
      db.exec("CREATE TABLE other_table (id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL);");
    } finally {
      db.close();
    }

    assert.deepEqual(readCronJobsByAgentId("assistant-1579723293989240833", dbPath), []);
  });
});

describe("gateway batch install protocol", () => {
  it("HELLO 声明批量安装与 ACK 能力", () => {
    const { client, replies } = createReplyingClient("gw-1");
    (client as unknown as { sendGatewayHello(): void }).sendGatewayHello();
    assert.equal(replies[0].type, "GATEWAY_HELLO");
    assert.equal(replies[0].supportsGatewayBatchInstall, true);
    assert.equal(replies[0].supportsDeliveryAck, true);
    assert.equal(replies[0].skillInstallProtocolVersion, 2);
  });

  it("GATEWAY_BATCH_INSTALL 持久化后 ACK，且不拆成 BATCH_COMMAND", async () => {
    const queueDir = fs.mkdtempSync(path.join(os.tmpdir(), "install-queue-"));
    const { client, replies } = (() => {
      const replies: Record<string, unknown>[] = [];
      const client = new GatewayWsClient({
        serverUrl: "ws://localhost:1",
        gatewayId: "gw-1",
        updater: {
          preparePackage: async () => ({ success: false, message: "skip download in protocol test" }),
          installFromPrepared: async () => ({ success: false, message: "skip" }),
          cleanupPrepared: async () => {},
        } as never,
        installQueuePath: path.join(queueDir, "queue.json"),
      });
      const socket = {
        readyState: 1,
        send(payload: string, callback?: (error?: Error) => void) {
          replies.push(JSON.parse(payload) as Record<string, unknown>);
          callback?.();
        },
      };
      Object.assign(client as object, { ws: socket });
      return { client, replies };
    })();
    await (client as unknown as { handleIncoming(value: Record<string, unknown>): Promise<void> }).handleIncoming({
      type: "GATEWAY_BATCH_INSTALL",
      dispatchId: "d1",
      action: "UPDATE_SKILL",
      code: "demo",
      version: "1.0.7",
      url: "https://example.invalid/demo.zip",
      commands: [
        { targetId: "t1", userId: "assistant-u1", replyId: "b1:t1:0", deliveryId: "b1:t1" },
      ],
    });
    const ack = replies.find((item) => item.type === "BATCH_ACK");
    assert.ok(ack);
    assert.deepEqual(ack?.acceptedReplyIds, ["b1:t1:0"]);
    assert.ok(fs.existsSync(path.join(queueDir, "queue.json")));
  });
});
