// #507 — send_reply attachments regression + parity with send_task.
//
// Before this PR: send_reply had no `attachments` field in its Zod schema.
// MCP's default schema-validation strips unknown fields, so a caller
// passing `attachments` would see `ok:true` and never learn the field
// was dropped. Nothing landed in `tasks.meta_json`. Silent failure.
//
// After this PR: schema declares `attachments` (top-level, parity with
// REST /api/task L506) and `meta` (parity with send_task L837). Handler
// validates via `validateAttachments` (uploads.ts:359 — the same helper
// the REST path uses), persists into inbox.meta_json + tasks.meta_json
// (parity with send_task L952/957), and **echoes attachments READ BACK
// FROM DB** in the response (lead 2b5f6634 catch: echoing the in-memory
// variable would still succeed if the UPDATE actually landed 0 rows —
// the echo needs to prove the DB write, not the caller's intent).
//
// Coverage:
//   Positive P1-P5    — attachments actually persist + echoed correctly
//   Reverse (e)       — no-attachments call is byte-identical to today
//   Negative N1-N6    — malformed attachments explicitly rejected
//   No-in_reply_to    — attachments still persisted onto inbox.meta_json
//   Cross-network     — same permission rules as bare send_reply

import { afterAll, beforeEach, describe, expect, test } from "bun:test";
import { db } from "./db.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { registerTools } from "./tools.js";

const NET_ID = "net_507_att";
const USER_ID = "u_507_att";
const NODE_ID = "node_507_att";
const AGENT_ALIAS = "peer-507";
const AGENT_TOK_ID = "tok_507_att";

interface ToolHandler {
  (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
}

interface Reply {
  ok?: boolean;
  message_id?: string;
  session_status?: string;
  warning?: string;
  error?: string;
  message?: string;
  attachments_saved?: Array<{ type: string; file_id: string; name?: string; mime?: string; size?: number }>;
  [k: string]: unknown;
}

function cleanup() {
  try { db.run("DELETE FROM tasks WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM inbox WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM task_events WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET_ID]); } catch {}
  try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
}

beforeEach(cleanup);
afterAll(cleanup);

function seed(): { taskId: string } {
  db.run(
    `INSERT INTO users (user_id, username, password_hash, role, created_at)
     VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
    [USER_ID, USER_ID],
  );
  db.run(
    `INSERT INTO networks (network_id, network_name, owner_id, created_at)
     VALUES (?1, ?2, ?3, datetime('now'))`,
    [NET_ID, NET_ID, USER_ID],
  );
  db.run(
    `INSERT INTO network_members (user_id, network_id, role, joined_at)
     VALUES (?1, ?2, 'owner', datetime('now'))`,
    [USER_ID, NET_ID],
  );
  db.run(
    `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
     VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'), 'active')`,
    [NODE_ID, AGENT_ALIAS, AGENT_ALIAS, NET_ID, `host-${AGENT_ALIAS}`],
  );
  db.run(
    `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
     VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
    [`res_${AGENT_ALIAS}`, AGENT_ALIAS, NODE_ID, NET_ID],
  );
  // Also seed hub session so alias="hub" is a valid dashboard-style target.
  db.run(
    `INSERT INTO sessions (resume_id, alias, status, network_id, updated_at, last_seen_at)
     VALUES ('res_hub_507', 'hub', 'idle', ?1, datetime('now'), datetime('now'))
     ON CONFLICT DO NOTHING`,
    [NET_ID],
  );
  // Seed a task the reply will target. Dashboard-style: from_name='hub',
  // to_name=agent, status='delivered'.
  const taskId = `task_507_${Date.now()}${Math.floor(Math.random() * 1000)}`;
  db.run(
    `INSERT INTO tasks (task_id, from_name, to_name, status, content, requires_response, created_at, delivered_at, expires_at, network_id, priority)
     VALUES (?1, 'hub', ?2, 'delivered', 'test task from dashboard', 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?3, 'normal')`,
    [taskId, AGENT_ALIAS, NET_ID],
  );
  return { taskId };
}

async function getSendReplyHandler(): Promise<ToolHandler> {
  const server = new McpServer({ name: "test", version: "0" });
  const tools: Record<string, ToolHandler> = {};
  const originalTool = (server as any).tool.bind(server);
  (server as any).tool = (name: string, ...rest: any[]) => {
    // send_reply is registered with (name, desc, schema, handler)
    const handler = rest[rest.length - 1];
    if (typeof handler === "function") tools[name] = handler;
    return originalTool(name, ...rest);
  };
  registerTools(server, {
    enforceNetworkId: NET_ID,
    enforceUserId: USER_ID,
    callerAlias: AGENT_ALIAS,
    callerTokenIsNetwork: true,
    callerTokenId: AGENT_TOK_ID,
  } as any);
  const handler = tools.send_reply;
  if (!handler) throw new Error("send_reply handler not registered");
  return handler;
}

async function callReply(handler: ToolHandler, args: any): Promise<Reply> {
  const result = await handler(args);
  return JSON.parse(result.content[0].text) as Reply;
}

// A valid file_id shape (matches FILE_ID_REGEX /^[A-Za-z0-9_-]{8,64}$/).
// Note: these attachments do NOT need to point at real uploaded files
// for the persistence/echo tests — validateAttachments only checks shape.
// The dashboard proxy resolves file_id to disk at download time separately.
function att(file_id: string, extra: Record<string, unknown> = {}) {
  return { type: "file", file_id, name: "test.bin", size: 1024, mime: "application/octet-stream", ...extra };
}

function readTaskMeta(taskId: string): any {
  const row = db.get<{ meta_json: string | null }>(
    "SELECT meta_json FROM tasks WHERE task_id = ?1", [taskId],
  );
  return row?.meta_json ? JSON.parse(row.meta_json) : null;
}

function readInboxMeta(msgId: string): any {
  const row = db.get<{ meta_json: string | null }>(
    "SELECT meta_json FROM inbox WHERE id = ?1", [msgId],
  );
  return row?.meta_json ? JSON.parse(row.meta_json) : null;
}

describe("#507 — send_reply attachments (positive)", () => {
  test("P1: two attachments persist to tasks.meta_json AND inbox.meta_json AND echo READ BACK FROM DB", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const a1 = att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "one.pdf" });
    const a2 = att("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", { name: "two.png" });
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "here you go",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [a1, a2],
    });
    expect(reply.ok).toBe(true);
    expect(reply.attachments_saved).toBeDefined();
    expect(reply.attachments_saved!.length).toBe(2);
    expect(reply.attachments_saved!.map((a) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());

    // Verify persistence — the echo must come from what actually landed on disk.
    const taskMeta = readTaskMeta(taskId);
    expect(taskMeta?.attachments?.length).toBe(2);
    expect(taskMeta.attachments.map((a: any) => a.file_id).sort()).toEqual([a1.file_id, a2.file_id].sort());

    const inboxMeta = readInboxMeta(reply.message_id!);
    expect(inboxMeta?.attachments?.length).toBe(2);
  });

  test("P2: meta.attachments (nested form) also accepted", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const a1 = att("cccccccccccccccccccccccccccccccc");
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "nested-form",
      in_reply_to: taskId,
      status: "replied" as const,
      meta: { attachments: [a1], other_field: "preserved" },
    });
    expect(reply.ok).toBe(true);
    expect(reply.attachments_saved!.length).toBe(1);
    const persisted = readTaskMeta(taskId);
    expect(persisted?.attachments?.[0]?.file_id).toBe(a1.file_id);
    expect(persisted?.other_field).toBe("preserved"); // meta merge preserves siblings
  });

  test("P3: top-level attachments WIN over meta.attachments when both supplied (parity with REST /api/task L2101)", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const topLevel = att("dddddddddddddddddddddddddddddddd", { name: "wins.pdf" });
    const nested = att("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", { name: "loses.pdf" });
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "conflict resolution",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [topLevel],
      meta: { attachments: [nested], other: "kept" },
    });
    expect(reply.ok).toBe(true);
    expect(reply.attachments_saved!.length).toBe(1);
    expect(reply.attachments_saved![0].file_id).toBe(topLevel.file_id);
    const persisted = readTaskMeta(taskId);
    expect(persisted?.attachments?.length).toBe(1);
    expect(persisted?.attachments?.[0]?.file_id).toBe(topLevel.file_id);
    expect(persisted?.other).toBe("kept");
  });
});

describe("#507 — reverse (e): no-attachments call is byte-identical to pre-#507", () => {
  test("R-e1: reply with no attachments field returns response without attachments_saved key at all", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "no attachments here",
      in_reply_to: taskId,
      status: "replied" as const,
    });
    expect(reply.ok).toBe(true);
    expect(reply.message_id).toBeDefined();
    // Reverse-(e) core: key must not appear at all (undefined, not empty).
    // If a caller had a strict equality assertion on the response shape,
    // adding an empty attachments_saved field would break it.
    expect("attachments_saved" in reply).toBe(false);
  });

  test("R-e2: reply with attachments=undefined returns response without attachments_saved key", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "explicit undefined",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: undefined,
    });
    expect(reply.ok).toBe(true);
    expect("attachments_saved" in reply).toBe(false);
  });

  test("R-e3: reply with attachments=[] (empty array) returns response without attachments_saved key", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "explicit empty array",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [],
    });
    expect(reply.ok).toBe(true);
    // Empty array is a valid input — validator returns ok+empty.
    // Response shape stays clean (no attachments_saved field).
    expect("attachments_saved" in reply).toBe(false);
  });

  test("R-e4: reply with no attachments does not touch pre-existing tasks.meta_json (COALESCE preserves)", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    // Pre-set a meta_json on the task row so we can prove COALESCE preserves it.
    db.run("UPDATE tasks SET meta_json = ?1 WHERE task_id = ?2", [
      JSON.stringify({ pre_existing: true, some_field: "must survive" }),
      taskId,
    ]);
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "no attachments — must not clobber existing meta",
      in_reply_to: taskId,
      status: "replied" as const,
    });
    expect(reply.ok).toBe(true);
    const meta = readTaskMeta(taskId);
    expect(meta?.pre_existing).toBe(true);
    expect(meta?.some_field).toBe("must survive");
  });
});

describe("#507 — negative: malformed attachments explicitly rejected (bad_attachments)", () => {
  test("N1: attachments not an array → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "malformed",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: "not an array" as any,
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
    expect(typeof reply.message).toBe("string");
    // Reply never landed on disk (validation happened before DB write).
    const meta = readTaskMeta(taskId);
    expect(meta).toBeNull();
  });

  test("N2: too many attachments (>20) → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    // 21 valid-shape attachments
    const many = Array.from({ length: 21 }, (_, i) => att("a".repeat(32).slice(0, 30) + (i < 10 ? "0" + i : "" + i)));
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "too many",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: many,
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
    expect(reply.message).toMatch(/too many/i);
  });

  test("N3: bad file_id (contains path separator) → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "bad file_id",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [{ type: "file", file_id: "../etc/passwd", name: "x", size: 1 }],
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
    expect(reply.message).toMatch(/file_id/i);
  });

  test("N4: wrong type field (must be 'file') → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "bad type",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [{ type: "url", file_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }],
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
    expect(reply.message).toMatch(/type/i);
  });

  test("N5: attachment size > MAX_UPLOAD_BYTES → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "too big",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { size: 999_999_999_999 })],
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
    expect(reply.message).toMatch(/size/i);
  });

  test("N6: attachment name too long (>255) → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "long name",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [att("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", { name: "x".repeat(256) })],
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
    expect(reply.message).toMatch(/name/i);
  });

  test("N7: non-object attachment item → 400 bad_attachments", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "junk item",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: ["string-not-object"] as any,
    });
    expect(reply.ok).toBe(false);
    expect(reply.error).toBe("bad_attachments");
  });
});

describe("#507 — echo integrity (read-back-from-DB, not in-memory)", () => {
  test("echo values are exactly what the DB round-trip produced (proves read-back not passthrough)", async () => {
    const { taskId } = seed();
    const handler = await getSendReplyHandler();
    const a = att("ffffffffffffffffffffffffffffffff", {
      name: "roundtrip.pdf",
      mime: "application/pdf",
      size: 4096,
    });
    const reply = await callReply(handler, {
      alias: AGENT_ALIAS,
      text: "roundtrip check",
      in_reply_to: taskId,
      status: "replied" as const,
      attachments: [a],
    });
    expect(reply.ok).toBe(true);
    // The echoed attachment must equal what DB round-trip gives back —
    // fetch independently and compare.
    const inboxMeta = readInboxMeta(reply.message_id!);
    const dbAttachment = inboxMeta?.attachments?.[0];
    expect(reply.attachments_saved![0]).toEqual(dbAttachment);
    // Also the echo must contain the full shape as re-hydrated from JSON
    // (proves it went through normalize/serialize/read/parse — not just
    // an in-memory pass-through).
    expect(reply.attachments_saved![0].file_id).toBe(a.file_id);
    expect(reply.attachments_saved![0].name).toBe(a.name);
    expect(reply.attachments_saved![0].mime).toBe(a.mime);
    expect(reply.attachments_saved![0].size).toBe(a.size);
    expect(reply.attachments_saved![0].type).toBe("file");
  });
});
