/**
 * Unit tests for BtmsgDb — in-memory SQLite.
 */
import { describe, it, expect, beforeEach } from "bun:test";
import { Database } from "bun:sqlite";
import { randomUUID } from "crypto";

const SCHEMA = `
CREATE TABLE IF NOT EXISTS agents (
  id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
  group_id TEXT NOT NULL, tier INTEGER NOT NULL DEFAULT 2,
  model TEXT, cwd TEXT, system_prompt TEXT,
  status TEXT DEFAULT 'stopped', last_active_at TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contacts (
  agent_id TEXT NOT NULL, contact_id TEXT NOT NULL,
  PRIMARY KEY (agent_id, contact_id)
);
CREATE TABLE IF NOT EXISTS messages (
  id TEXT PRIMARY KEY, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL,
  content TEXT NOT NULL, read INTEGER DEFAULT 0, reply_to TEXT,
  group_id TEXT NOT NULL, sender_group_id TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent, read);
CREATE TABLE IF NOT EXISTS channels (
  id TEXT PRIMARY KEY, name TEXT NOT NULL, group_id TEXT NOT NULL,
  created_by TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS channel_members (
  channel_id TEXT NOT NULL, agent_id TEXT NOT NULL,
  joined_at TEXT DEFAULT (datetime('now')),
  PRIMARY KEY (channel_id, agent_id)
);
CREATE TABLE IF NOT EXISTS channel_messages (
  id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, from_agent TEXT NOT NULL,
  content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS heartbeats (
  agent_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS seen_messages (
  session_id TEXT NOT NULL, message_id TEXT NOT NULL,
  seen_at INTEGER NOT NULL DEFAULT (unixepoch()),
  PRIMARY KEY (session_id, message_id)
);
`;

function createDb(): Database {
  const db = new Database(":memory:");
  db.exec("PRAGMA foreign_keys = ON");
  db.exec(SCHEMA);
  return db;
}

function registerAgent(db: Database, id: string, name: string, role: string, groupId: string, tier = 2) {
  db.query(
    `INSERT INTO agents (id, name, role, group_id, tier)
     VALUES (?1, ?2, ?3, ?4, ?5)
     ON CONFLICT(id) DO UPDATE SET name=excluded.name, role=excluded.role, group_id=excluded.group_id`
  ).run(id, name, role, groupId, tier);
}

function sendMessage(db: Database, fromAgent: string, toAgent: string, content: string): string {
  const sender = db.query<{ group_id: string }, [string]>(
    "SELECT group_id FROM agents WHERE id = ?"
  ).get(fromAgent);
  if (!sender) throw new Error(`Sender '${fromAgent}' not found`);

  const recipient = db.query<{ group_id: string }, [string]>(
    "SELECT group_id FROM agents WHERE id = ?"
  ).get(toAgent);
  if (!recipient) throw new Error(`Recipient '${toAgent}' not found`);
  if (sender.group_id !== recipient.group_id) {
    throw new Error(`Cross-group messaging denied`);
  }

  const id = randomUUID();
  db.query(
    `INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id)
     VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
  ).run(id, fromAgent, toAgent, content, sender.group_id);
  return id;
}

describe("BtmsgDb", () => {
  let db: Database;

  beforeEach(() => {
    db = createDb();
  });

  // ── Agent registration ────────────────────────────────────────────────

  describe("registerAgent", () => {
    it("inserts a new agent", () => {
      registerAgent(db, "mgr-1", "Manager", "manager", "grp-a", 1);
      const agents = db.query<{ id: string; role: string }, [string]>(
        "SELECT id, role FROM agents WHERE group_id = ?"
      ).all("grp-a");
      expect(agents).toHaveLength(1);
      expect(agents[0].role).toBe("manager");
    });

    it("upserts on conflict", () => {
      registerAgent(db, "a1", "Old", "dev", "g1");
      registerAgent(db, "a1", "New", "tester", "g1");
      const row = db.query<{ name: string; role: string }, [string]>(
        "SELECT name, role FROM agents WHERE id = ?"
      ).get("a1");
      expect(row!.name).toBe("New");
      expect(row!.role).toBe("tester");
    });
  });

  // ── Direct messaging ──────────────────────────────────────────────────

  describe("sendMessage", () => {
    it("sends a message between agents in same group", () => {
      registerAgent(db, "a1", "A1", "dev", "grp");
      registerAgent(db, "a2", "A2", "dev", "grp");
      const msgId = sendMessage(db, "a1", "a2", "hello");
      expect(msgId).toBeTruthy();

      const row = db.query<{ content: string }, [string]>(
        "SELECT content FROM messages WHERE id = ?"
      ).get(msgId);
      expect(row!.content).toBe("hello");
    });

    it("rejects cross-group messaging", () => {
      registerAgent(db, "a1", "A1", "dev", "grp-a");
      registerAgent(db, "a2", "A2", "dev", "grp-b");
      expect(() => sendMessage(db, "a1", "a2", "nope")).toThrow("Cross-group");
    });

    it("throws for unknown sender", () => {
      registerAgent(db, "a2", "A2", "dev", "grp");
      expect(() => sendMessage(db, "unknown", "a2", "hi")).toThrow("not found");
    });

    it("throws for unknown recipient", () => {
      registerAgent(db, "a1", "A1", "dev", "grp");
      expect(() => sendMessage(db, "a1", "unknown", "hi")).toThrow("not found");
    });
  });

  describe("listMessages", () => {
    it("returns messages between two agents in order", () => {
      registerAgent(db, "a1", "A1", "dev", "g");
      registerAgent(db, "a2", "A2", "dev", "g");
      sendMessage(db, "a1", "a2", "msg1");
      sendMessage(db, "a2", "a1", "msg2");

      const rows = db.query<{ content: string }, [string, string, string, string, number]>(
        `SELECT content FROM messages
         WHERE (from_agent = ?1 AND to_agent = ?2) OR (from_agent = ?3 AND to_agent = ?4)
         ORDER BY created_at ASC LIMIT ?5`
      ).all("a1", "a2", "a2", "a1", 50);
      expect(rows).toHaveLength(2);
    });
  });

  describe("markRead", () => {
    it("marks messages as read", () => {
      registerAgent(db, "a1", "A1", "dev", "g");
      registerAgent(db, "a2", "A2", "dev", "g");
      const id = sendMessage(db, "a1", "a2", "hello");

      db.query("UPDATE messages SET read = 1 WHERE id = ? AND to_agent = ?").run(id, "a2");

      const row = db.query<{ read: number }, [string]>(
        "SELECT read FROM messages WHERE id = ?"
      ).get(id);
      expect(row!.read).toBe(1);
    });

    it("no-ops for empty array", () => {
      // Just ensure no crash
      expect(() => {}).not.toThrow();
    });
  });

  // ── Channels ──────────────────────────────────────────────────────────

  describe("createChannel", () => {
    it("creates channel and auto-adds creator as member", () => {
      registerAgent(db, "creator", "Creator", "manager", "g");
      const channelId = randomUUID();

      const tx = db.transaction(() => {
        db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run(
          channelId, "general", "g", "creator"
        );
        db.query("INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(
          channelId, "creator"
        );
      });
      tx();

      const members = db.query<{ agent_id: string }, [string]>(
        "SELECT agent_id FROM channel_members WHERE channel_id = ?"
      ).all(channelId);
      expect(members).toHaveLength(1);
      expect(members[0].agent_id).toBe("creator");
    });
  });

  describe("joinChannel / leaveChannel", () => {
    it("join adds member, leave removes", () => {
      registerAgent(db, "a1", "A1", "dev", "g");
      const chId = randomUUID();
      db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run(
        chId, "ch", "g", "a1"
      );

      db.query("INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1");
      let members = db.query<{ agent_id: string }, [string]>(
        "SELECT agent_id FROM channel_members WHERE channel_id = ?"
      ).all(chId);
      expect(members).toHaveLength(1);

      db.query("DELETE FROM channel_members WHERE channel_id = ? AND agent_id = ?").run(chId, "a1");
      members = db.query<{ agent_id: string }, [string]>(
        "SELECT agent_id FROM channel_members WHERE channel_id = ?"
      ).all(chId);
      expect(members).toHaveLength(0);
    });

    it("joinChannel on nonexistent channel throws", () => {
      registerAgent(db, "a1", "A1", "dev", "g");
      const chId = "nonexistent";
      const ch = db.query<{ id: string }, [string]>(
        "SELECT id FROM channels WHERE id = ?"
      ).get(chId);
      expect(ch).toBeNull();
    });
  });

  describe("sendChannelMessage", () => {
    it("allows member to send message", () => {
      registerAgent(db, "a1", "A1", "dev", "g");
      const chId = randomUUID();
      db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run(
        chId, "ch", "g", "a1"
      );
      db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1");

      const msgId = randomUUID();
      db.query("INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?, ?, ?, ?)").run(
        msgId, chId, "a1", "hello channel"
      );

      const row = db.query<{ content: string }, [string]>(
        "SELECT content FROM channel_messages WHERE id = ?"
      ).get(msgId);
      expect(row!.content).toBe("hello channel");
    });

    it("rejects non-member", () => {
      registerAgent(db, "a1", "A1", "dev", "g");
      registerAgent(db, "a2", "A2", "dev", "g");
      const chId = randomUUID();
      db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run(
        chId, "ch", "g", "a1"
      );
      db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1");

      // Check membership before inserting
      const member = db.query<{ agent_id: string }, [string, string]>(
        "SELECT agent_id FROM channel_members WHERE channel_id = ? AND agent_id = ?"
      ).get(chId, "a2");
      expect(member).toBeNull();
    });
  });

  describe("getChannelMembers", () => {
    it("returns members with name and role", () => {
      registerAgent(db, "a1", "Alice", "dev", "g");
      registerAgent(db, "a2", "Bob", "tester", "g");
      const chId = randomUUID();
      db.query("INSERT INTO channels (id, name, group_id, created_by) VALUES (?, ?, ?, ?)").run(
        chId, "ch", "g", "a1"
      );
      db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a1");
      db.query("INSERT INTO channel_members (channel_id, agent_id) VALUES (?, ?)").run(chId, "a2");

      const members = db.query<{ agent_id: string; name: string; role: string }, [string]>(
        `SELECT cm.agent_id, a.name, a.role FROM channel_members cm
         JOIN agents a ON cm.agent_id = a.id WHERE cm.channel_id = ?`
      ).all(chId);
      expect(members).toHaveLength(2);
      expect(members.map((m) => m.name).sort()).toEqual(["Alice", "Bob"]);
    });
  });

  // ── Heartbeat ─────────────────────────────────────────────────────────

  describe("heartbeat", () => {
    it("upserts heartbeat timestamp", () => {
      const now = Math.floor(Date.now() / 1000);
      db.query(
        "INSERT INTO heartbeats (agent_id, timestamp) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp"
      ).run("a1", now);

      const row = db.query<{ timestamp: number }, [string]>(
        "SELECT timestamp FROM heartbeats WHERE agent_id = ?"
      ).get("a1");
      expect(row!.timestamp).toBe(now);

      // Update
      db.query(
        "INSERT INTO heartbeats (agent_id, timestamp) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp"
      ).run("a1", now + 60);
      const row2 = db.query<{ timestamp: number }, [string]>(
        "SELECT timestamp FROM heartbeats WHERE agent_id = ?"
      ).get("a1");
      expect(row2!.timestamp).toBe(now + 60);
    });
  });

  // ── Seen messages (pruneSeen) ─────────────────────────────────────────

  describe("pruneSeen", () => {
    it("deletes old seen_messages entries", () => {
      // Insert with an old timestamp (manually set seen_at)
      db.query(
        "INSERT INTO seen_messages (session_id, message_id, seen_at) VALUES (?, ?, ?)"
      ).run("s1", "m1", 1000); // ancient timestamp
      db.query(
        "INSERT INTO seen_messages (session_id, message_id, seen_at) VALUES (?, ?, ?)"
      ).run("s1", "m2", Math.floor(Date.now() / 1000)); // recent

      const result = db.query(
        "DELETE FROM seen_messages WHERE seen_at < unixepoch() - ?"
      ).run(3600); // 1 hour max age
      expect((result as { changes: number }).changes).toBe(1);

      const remaining = db.query<{ message_id: string }, []>(
        "SELECT message_id FROM seen_messages"
      ).all();
      expect(remaining).toHaveLength(1);
      expect(remaining[0].message_id).toBe("m2");
    });
  });
});
