import { afterEach, expect, test } from "bun:test";
import { createApi } from "../api";
import { fixture } from "../habits/test-fixture";
import type { DiscordFetch } from "./routes";
import type { DiscordSharingConfig } from "./config";
import { cardStats } from "../lib/progress-card";

const fixtures: ReturnType<typeof fixture>[] = [];
afterEach(() => { for (const f of fixtures.splice(0)) f.close(); });
const botConfig = { token: "test-bot-token-do-not-expose", channelId: "223456789012345678" };
const channel = { type: 0, id: botConfig.channelId, guild_id: "323456789012345678", name: "progress" };
function setup(request: DiscordFetch = async () => Response.json(channel), config: DiscordSharingConfig = botConfig) {
  const f = fixture(); fixtures.push(f);
  let timestamp = Date.parse("2026-09-04T12:00:00Z");
  const app = createApi(f.db, { origin: f.origin, clientId: "", clientSecret: "", cookieSecret: "test-secret-with-at-least-32-characters" }, undefined, () => timestamp, request, config);
  const call = (path: string, method = "GET", body?: unknown, user = "a", origin = f.origin) => app.request(`${f.origin}/api/sharing${path}`, { method, headers: { Cookie: `minabot_session=${user.repeat(43)}`, Origin: origin, ...(body instanceof FormData ? {} : { "Content-Type": "application/json" }) }, body: body instanceof FormData ? body : body === undefined ? undefined : JSON.stringify(body) });
  return { f, call, advance: (seconds: number) => { timestamp += seconds * 1000; }, connect: () => call("/discord") };
}
// A small real PNG is not 1200px wide; use a header fixture for transport validation.
function upload(id = crypto.randomUUID()) {
  const header = Buffer.alloc(40); Buffer.from("89504e470d0a1a0a", "hex").copy(header); header.write("IHDR", 12); header.writeUInt32BE(1200, 16); header.writeUInt32BE(940, 20);
  const data = new FormData(); data.set("image", new Blob([header], { type: "image/png" }), "progress.png"); data.set("deliveryId", id); return data;
}

test("sharing requires authentication and same-origin mutations", async () => {
  const { call } = setup();
  expect((await call("/discord", "GET", undefined, "x")).status).toBe(401);
  expect((await call("/discord/send", "POST", upload(), "a", "https://evil.example")).status).toBe(403);
  expect((await call("/preview", "POST", {}, "a", "https://evil.example")).status).toBe(403);
});
test("missing or invalid bot configuration cannot make outbound requests", async () => {
  for (const config of [{ token: "", channelId: channel.id }, { token: "test", channelId: "https://evil.example" }, { token: "bad\nheader", channelId: channel.id }]) {
    let requests = 0;
    const { call } = setup(async () => { requests++; return Response.json(channel); }, config);
    expect((await (await call("/discord")).json()).connected).toBe(false);
    expect((await call("/discord/send", "POST", upload())).status).toBe(422);
    expect(requests).toBe(0);
  }
});
test("bot channel status exposes only the configured destination and caches channel metadata", async () => {
  let requests = 0;
  const { call } = setup(async (url, init) => {
    requests++; expect(url).toBe(`https://discord.com/api/v10/channels/${channel.id}`);
    expect(init?.redirect).toBe("error"); expect(new Headers(init?.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
    return Response.json(channel);
  });
  const expected = { connected: true, name: "#progress", channelUrl: `https://discord.com/channels/${channel.guild_id}/${channel.id}` };
  const response = await call("/discord"); expect(response.status).toBe(200); expect(await response.json()).toEqual(expected);
  expect(await (await call("/discord", "GET", undefined, "b")).json()).toEqual(expected);
  expect(requests).toBe(1);
  expect((await call("/discord", "PUT", { url: "https://evil.example" })).status).toBe(404);
  expect((await call("/discord", "DELETE")).status).toBe(404);
});
test("bot errors do not expose the token or provider response", async () => {
  const { call } = setup(async () => { throw new Error(botConfig.token); });
  const response = await call("/discord"); expect(response.status).toBe(502); expect(await response.text()).not.toContain(botConfig.token);
});
test("card data respects ownership, recurrence, privacy boundaries, and date limits", async () => {
  const { f, call } = setup();
  f.setTime("2026-09-03T12:00:00Z");
  const habit = await f.json("/habits", "POST", { name: "Evening", method: "tasks", tasks: [{ name: "private task name" }], schedule: { type: "weekdays", days: [4] } }, 201);
  await f.json(`/habits/${habit.id}/days/2026-09-03/tasks/${habit.tasks[0].id}`, "PUT", { done: true });
  f.setTime("2026-09-04T12:00:00Z");
  const input = { habitIds: [habit.id], from: "2026-09-01", to: "2026-09-04" };
  const response = await call("/preview", "POST", input); expect(response.status).toBe(200);
  const data = await response.json(); expect(JSON.stringify(data)).not.toContain("private task name");
  expect(cardStats(data)).toEqual({ completed: 1, scheduled: 1, percent: 100 });
  expect(data.habits[0].legend).toEqual({ empty: "#ebedf0", partial: [], complete: "#196127" });
  expect(data.habits[0].days[3].due).toBe(false);
  expect((await call("/preview", "POST", input, "b")).status).toBe(404);
  for (const patch of [{ to: "2026-09-05" }, { from: "2024-01-01" }, { from: "2026-09-05" }, { habitIds: [] }, { habitIds: [habit.id, habit.id] }]) expect((await call("/preview", "POST", { ...input, ...patch })).status).toBe(422);
});
test("delivery sends exactly the PNG with mentions disabled and deduplicates retries", async () => {
  let posts = 0;
  const { call, connect } = setup(async (target, init) => {
    if (init?.method !== "POST") return Response.json(channel);
    posts++; expect(target).toBe(`https://discord.com/api/v10/channels/${channel.id}/messages`);
    expect(new Headers(init.headers).get("Authorization")).toBe(`Bot ${botConfig.token}`);
    const form = init.body as FormData; const payload = JSON.parse(form.get("payload_json") as string);
    expect(payload.allowed_mentions).toEqual({ parse: [] }); expect(payload.username).toBeUndefined(); expect(payload.enforce_nonce).toBe(true); expect(payload.nonce).toHaveLength(24);
    expect(payload.content).toBeUndefined(); expect((form.get("files[0]") as File).type).toBe("image/png");
    return Response.json({ id: "423456789012345678" });
  });
  await connect(); const id = crypto.randomUUID();
  const first = await call("/discord/send", "POST", upload(id)); expect(first.status).toBe(200);
  expect(await first.json()).toEqual({ status: "sent", messageUrl: "https://discord.com/channels/323456789012345678/223456789012345678/423456789012345678" });
  expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("sent"); expect(posts).toBe(1);
});
test("uncertain delivery is not resent and a known rate-limit rejection can be retried", async () => {
  let posts = 0;
  const { call, connect, advance } = setup(async (_, init) => {
    if (init?.method !== "POST") return Response.json(channel);
    posts++; if (posts === 1) return new Response(null, { status: 429 }); throw new Error("Network failed after sending");
  });
  await connect(); const id = crypto.randomUUID();
  expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
  expect((await call("/discord/send", "POST", upload(id))).status).toBe(429);
  advance(60);
  expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain");
  expect((await (await call("/discord/send", "POST", upload(id))).json()).status).toBe("uncertain"); expect(posts).toBe(2);
});
test("invalid files and absent bot configuration cannot send", async () => {
  let posts = 0; const { call, connect } = setup(async (_, init) => { if (init?.method === "POST") posts++; return Response.json(channel); });
  const disabled = setup(undefined, { token: "", channelId: "" });
  expect((await disabled.call("/discord/send", "POST", upload())).status).toBe(422);
  await connect(); const form = upload(); form.set("image", new Blob(["not png"], { type: "image/png" }), "x.png");
  expect((await call("/discord/send", "POST", form)).status).toBe(422); expect(posts).toBe(0);
});


test("posting quotas block new deliveries, allow duplicate checks, and expire", async () => {
  let posts = 0;
  const { f, call, advance } = setup(async (_, init) => {
    if (init?.method === "POST") { posts++; return Response.json({ id: "423456789012345678" }); }
    return Response.json(channel);
  });
  const id = crypto.randomUUID();
  expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
  expect((await call("/discord/send", "POST", upload(id))).status).toBe(200);
  const limited = await call("/discord/send", "POST", upload());
  expect(limited.status).toBe(429); expect(limited.headers.get("retry-after")).toBe("60");
  expect((await call("/discord/send", "POST", upload(), "b")).status).toBe(200);
  expect(posts).toBe(2);
  f.sqlite.query("UPDATE sharing_limits SET count = 10 WHERE key = ?").run(`channel:${channel.id}`);
  advance(30);
  expect((await call("/discord/send", "POST", upload())).status).toBe(429);
  advance(30);
  expect((await call("/discord/send", "POST", upload())).status).toBe(200);
  expect(posts).toBe(3);
});

test("an allowlist blocks unauthorized posting without preventing exports", async () => {
  const { call } = setup(undefined, { ...botConfig, allowedUserIds: ["bob"] });
  expect((await call("/discord/send", "POST", upload())).status).toBe(403);
  expect((await (await call("/discord")).json()).connected).toBe(false);
  expect((await (await call("/discord", "GET", undefined, "b")).json()).connected).toBe(true);
});
