import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { eq } from "drizzle-orm";
import { createApi } from "../api";
import * as schema from "../db/schema";
import { hashToken, normalizeTimezone } from "./index";
import { readAuthConfig, type AuthConfig } from "./config";
import type { DiscordProfile, FetchDiscord } from "./discord";

const origin = "http://127.0.0.1:3000";
const config: AuthConfig = { origin, clientId: "test-client", clientSecret: "test-secret", cookieSecret: "test-only-signing-secret-at-least-32-characters" };
let sqlite: Database;
let db: ReturnType<typeof drizzle<typeof schema>>;
let app: ReturnType<typeof createApi>;
let timestamp: number;
let calls: { url: string; init: RequestInit }[];
let profile: DiscordProfile;
let failure: "token" | "invalid_token" | "profile" | "malformed" | "throw" | undefined;
const request: FetchDiscord = async (url, init) => {
  calls.push({ url, init });
  if (failure === "throw") throw new Error("provider secret response");
  if (url.endsWith("/token")) {
    if (failure === "token") return new Response("provider secret response", { status: 400 });
    if (failure === "invalid_token") return Response.json({ access_token: "secret-access", token_type: "Bearer", scope: "email" });
    return Response.json({ access_token: "secret-access", refresh_token: "secret-refresh", token_type: "Bearer", scope: "identify" });
  }
  if (failure === "profile") return new Response("provider secret response", { status: 503 });
  return Response.json(failure === "malformed" ? { id: 123, username: "bad" } : profile);
};
const cookie = (response: Response, name: string) => response.headers.getSetCookie().find(value => value.startsWith(`${name}=`))?.split(";")[0] ?? "";
async function begin(timezone = "Europe/Belgrade", target = app, base = origin) {
  const response = await target.request(`${base}/api/auth/discord?${new URLSearchParams({ timezone })}`);
  const location = new URL(response.headers.get("Location")!);
  return { response, state: location.searchParams.get("state")!, cookie: cookie(response, base.startsWith("https:") ? "__Host-minabot_oauth" : "minabot_oauth") };
}
async function login(timezone = "Europe/Belgrade", previous = "") {
  const flow = await begin(timezone);
  const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: flow.state, code: "test-code" })}`, {
    headers: { Cookie: [flow.cookie, previous].filter(Boolean).join("; ") },
  });
  return { response, cookie: cookie(response, "minabot_session") };
}

beforeEach(() => {
  sqlite = new Database(":memory:");
  sqlite.exec("PRAGMA foreign_keys = ON");
  db = drizzle(sqlite, { schema });
  migrate(db, { migrationsFolder: "./drizzle" });
  timestamp = Date.now(); calls = []; failure = undefined;
  profile = { id: "123456789012345678", username: "mina", global_name: "Mina", avatar: null };
  app = createApi(db, config, request, () => timestamp);
});
afterEach(() => sqlite.close());

describe("Discord sign-in and sessions", () => {
  test("me requires a valid session and health stays public", async () => {
    for (const value of ["", "minabot_session=malformed", `minabot_session=${"a".repeat(43)}`]) {
      const response = await app.request(`${origin}/api/me`, { headers: { Cookie: value } });
      expect(response.status).toBe(401);
      expect(await response.json()).toEqual({ error: "Unauthorized" });
      expect(response.headers.get("Cache-Control")).toBe("no-store");
    }
    expect((await app.request(`${origin}/api/health`)).status).toBe(200);
    expect((await app.request(`${origin}/api/missing`)).status).toBe(404);
  });

  test("authorization uses identify, random state, and a signed expiring HttpOnly cookie", async () => {
    const first = await begin(); const second = await begin();
    const url = new URL(first.response.headers.get("Location")!);
    expect(url.origin).toBe("https://discord.com");
    expect(url.searchParams.get("scope")).toBe("identify");
    expect(url.searchParams.get("redirect_uri")).toBe(`${origin}/api/auth/discord/callback`);
    expect(first.state).not.toBe(second.state);
    const header = first.response.headers.get("Set-Cookie")!;
    expect(header).toContain("HttpOnly"); expect(header).toContain("SameSite=Lax"); expect(header).toContain("Max-Age=600");
  });

  test("callback creates a user and hashed session, and me returns only public fields", async () => {
    const result = await login();
    expect(result.response.headers.get("Location")).toBe("/");
    const user = db.select().from(schema.users).get()!;
    const session = db.select().from(schema.sessions).get()!;
    expect(user.timezone).toBe("Europe/Belgrade");
    expect(session.tokenHash).toBe(hashToken(result.cookie.split("=")[1]!));
    expect(session.expiresAt - session.createdAt).toBe(30 * 24 * 60 * 60 * 1000);
    expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=2592000");
    expect(result.response.headers.getSetCookie().join(";")).toContain("Max-Age=0");
    const response = await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } });
    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ id: user.id, discordId: profile.id, username: "mina", displayName: "Mina", avatarUrl: null, timezone: "Europe/Belgrade" });
    const body = calls[0]!.init.body as URLSearchParams;
    expect(body.get("client_secret")).toBe(config.clientSecret);
    expect(body.get("code")).toBe("test-code");
    expect(calls[1]!.init.headers).toEqual({ Authorization: "Bearer secret-access" });
    expect(JSON.stringify({ user, session })).not.toContain("secret-access");
  });

  test("repeat login updates profile but preserves ID, creation time and timezone; rotates session", async () => {
    const first = await login();
    const oldUser = db.select().from(schema.users).get()!;
    timestamp += 1000;
    profile = { ...profile, username: "new-name", global_name: null, avatar: "a_abcdef" };
    const second = await login("America/New_York", first.cookie);
    expect(db.select().from(schema.users).all()).toHaveLength(1);
    const user = db.select().from(schema.users).get()!;
    expect(user.id).toBe(oldUser.id); expect(user.createdAt).toBe(oldUser.createdAt);
    expect(user.updatedAt).toBe(timestamp); expect(user.timezone).toBe("Europe/Belgrade");
    expect(db.select().from(schema.sessions).all()).toHaveLength(1);
    expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401);
    const response = await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } });
    const data = await response.json();
    expect(data.displayName).toBe("new-name");
    expect(data.avatarUrl).toBe(`https://cdn.discordapp.com/avatars/${profile.id}/a_abcdef.gif`);
  });

  test("invalid or missing timezone falls back to UTC", async () => {
    expect(normalizeTimezone(undefined)).toBe("UTC");
    expect(normalizeTimezone("+02:00")).toBe("UTC");
    expect(normalizeTimezone("Europe/Belgrade")).toBe("Europe/Belgrade");
    await login("invalid/timezone");
    expect(db.select().from(schema.users).get()!.timezone).toBe("UTC");
  });

  test("rejects missing, mismatched, tampered and expired OAuth state before contacting Discord", async () => {
    const flow = await begin();
    const cases = [
      { state: flow.state, cookie: "" },
      { state: "b".repeat(43), cookie: flow.cookie },
      { state: flow.state, cookie: `${flow.cookie}tampered` },
      { state: "", cookie: flow.cookie },
    ];
    for (const value of cases) {
      const response = await app.request(`${origin}/api/auth/discord/callback?${new URLSearchParams({ state: value.state, code: "test-code" })}`, { headers: { Cookie: value.cookie } });
      expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state");
    }
    timestamp += 600_000;
    const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } });
    expect(response.headers.get("Location")).toBe("/?auth_error=invalid_state");
    expect(calls).toHaveLength(0); expect(db.select().from(schema.users).all()).toHaveLength(0);
  });

  test("handles consent denial and missing code without creating a session", async () => {
    for (const [query, error] of [["error=access_denied", "denied"], ["", "invalid_code"]]) {
      const flow = await begin();
      const response = await app.request(`${origin}/api/auth/discord/callback?state=${flow.state}&${query}`, { headers: { Cookie: flow.cookie } });
      expect(response.headers.get("Location")).toBe(`/?auth_error=${error}`);
    }
    expect(calls).toHaveLength(0); expect(db.select().from(schema.sessions).all()).toHaveLength(0);
  });

  test.each(["token", "invalid_token", "profile", "malformed", "throw"] as const)("provider failure (%s) leaves no account or session", async value => {
    failure = value;
    const result = await login();
    expect(result.response.headers.get("Location")).toBe("/?auth_error=discord_unavailable");
    expect(result.cookie).toBe("");
    expect(db.select().from(schema.users).all()).toHaveLength(0);
    expect(db.select().from(schema.sessions).all()).toHaveLength(0);
  });

  test("expired sessions are rejected at their exact expiry", async () => {
    const result = await login();
    timestamp = db.select().from(schema.sessions).get()!.expiresAt;
    expect((await app.request(`${origin}/api/me`, { headers: { Cookie: result.cookie } })).status).toBe(401);
    expect(db.select().from(schema.sessions).all()).toHaveLength(0);
  });

  test("logout rejects cross-origin or missing origins and revokes only the current session", async () => {
    const first = await login(); const second = await login();
    for (const headers of [new Headers({ Cookie: first.cookie }), new Headers({ Cookie: first.cookie, Origin: "https://evil.example" })]) {
      expect((await app.request(`${origin}/api/auth/logout`, { method: "POST", headers })).status).toBe(403);
    }
    expect(db.select().from(schema.sessions).all()).toHaveLength(2);
    const response = await app.request(`${origin}/api/auth/logout`, { method: "POST", headers: { Cookie: first.cookie, Origin: origin } });
    expect(response.status).toBe(204); expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0");
    expect((await app.request(`${origin}/api/me`, { headers: { Cookie: first.cookie } })).status).toBe(401);
    expect((await app.request(`${origin}/api/me`, { headers: { Cookie: second.cookie } })).status).toBe(200);
    expect((await app.request(`${origin}/api/auth/logout`)).status).toBe(404);
  });

  test("foreign keys cascade session deletion and duplicate Discord IDs are rejected", async () => {
    await login();
    const user = db.select().from(schema.users).get()!;
    expect(() => db.insert(schema.users).values({ ...user, id: "different" }).run()).toThrow();
    db.delete(schema.users).where(eq(schema.users.id, user.id)).run();
    expect(db.select().from(schema.sessions).all()).toHaveLength(0);
  });

  test("HTTPS cookies are Secure and use host-only names", async () => {
    const secureConfig = { ...config, origin: "https://habits.example" };
    const secureApp = createApi(db, secureConfig, request, () => timestamp);
    const flow = await begin("UTC", secureApp, secureConfig.origin);
    const response = await secureApp.request(`${secureConfig.origin}/api/auth/discord/callback?state=${flow.state}&code=test-code`, { headers: { Cookie: flow.cookie } });
    expect(response.headers.getSetCookie().find(value => value.startsWith("__Host-minabot_session="))).toContain("Secure");
    expect(flow.response.headers.get("Set-Cookie")).toContain("Secure");
  });

  test("missing credentials fail gracefully and unsafe configured origins fail fast", async () => {
    const unconfigured = createApi(db, { ...config, clientSecret: "" }, request);
    const response = await unconfigured.request(`${origin}/api/auth/discord`);
    expect(response.headers.get("Location")).toBe("/?auth_error=not_configured");
    expect(calls).toHaveLength(0);
    expect(() => readAuthConfig({ APP_ORIGIN: "http://habits.example" })).toThrow();
    expect(() => readAuthConfig({ APP_ORIGIN: "https://habits.example/path" })).toThrow();
  });
});
