/**
 * Auth flow integration tests (via app.request() injection): admin seeding / login /
 * logout / password change / session / initial Project.
 */
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MeResponse, ProjectsResponse } from "../src/api/types.js";
import { buildAppDeps } from "../src/app.js";
import { generateInitialAdminPassword } from "../src/auth/service.js";
import {
  apiClient,
  createTestApp,
  loginAdmin,
  loginUser,
  makeTempRoot,
  provisionUser,
  TEST_ADMIN_PASSWORD,
  testConfig,
} from "./helpers.js";
import type { TestApp } from "./helpers.js";

describe("auth", () => {
  let t: TestApp;

  beforeEach(async () => {
    t = await createTestApp();
  });
  afterEach(async () => {
    await t.cleanup();
  });

  it("accessing a protected API while not logged in returns 401", async () => {
    const res = await t.app.request("/api/projects");
    expect(res.status).toBe(401);
    const body = (await res.json()) as { error: { code: string } };
    expect(body.error.code).toBe("unauthorized");
  });

  it("registration is closed: no register endpoint", async () => {
    // Not logged in: no such route under /api/auth, falls into the protected-section 401.
    const anon = await t.app.request("/api/auth/register", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ userId: "alice", password: "password-123" }),
    });
    expect(anon.status).toBe(401);
    // Logged in: falls through to notFound -> 404, proving the route has indeed been removed.
    const admin = await loginAdmin(t.app);
    const res = await apiClient(t.app, admin.cookie).post("/api/auth/register", {
      userId: "alice",
      password: "password-123",
    });
    expect(res.status).toBe(404);
  });

  it("seeded admin manages default_project; initial password carries the flag", async () => {
    const admin = await loginAdmin(t.app);
    expect(admin.user.isAdmin).toBe(true);
    expect(admin.user.passwordIsInitial).toBe(true);
    const api = apiClient(t.app, admin.cookie);
    const projects = (await (await api.get("/api/projects")).json()) as ProjectsResponse;
    expect(projects.projects.map((p) => p.projectId)).toContain("default_project");
    expect(projects.projects[0]!.role).toBe("owner");
    // default_agent has been initialized (directory exists).
    await expect(
      fs.access(path.join(t.root, "default_project", "agents", "default_agent", "agent_state")),
    ).resolves.toBeUndefined();
    // Seeding is idempotent: re-seeding returns null and does not create a duplicate account.
    expect(await t.deps.authService.seedAdmin()).toBeNull();
    expect(t.deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(1);
  });

  it("admin-created: default Project is <userId>-default_project, name defaults", async () => {
    const bob = await provisionUser(t.app, "bob");
    expect(bob.user.isAdmin).toBe(false);
    expect(bob.user.passwordIsInitial).toBe(true);
    const api = apiClient(t.app, bob.cookie);
    const projects = (await (await api.get("/api/projects")).json()) as ProjectsResponse;
    expect(projects.projects).toHaveLength(1);
    const p = projects.projects[0]!;
    expect(p.projectId).toBe("bob-default_project");
    expect(p.name).toBe("bob");
    expect(p.role).toBe("owner");
    expect(p.ownerUserId).toBe("bob");
    // The initial Project's .project_config.toml carries the display name and preset model config (the default model is written along with it).
    const toml = await fs.readFile(
      path.join(t.root, "bob-default_project", ".project_config.toml"),
      "utf8",
    );
    expect(toml).toContain('name = "bob"');
    expect(toml).toContain(
      'default_model = { provider = "deepseek", model_id = "deepseek-v4-flash" }',
    );
  });

  it("login / me / logout round trip; wrong password 401", async () => {
    await provisionUser(t.app, "carol");
    const wrong = await t.app.request("/api/auth/login", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ userId: "carol", password: "wrong-password" }),
    });
    expect(wrong.status).toBe(401);

    const { cookie } = await loginUser(t.app, "carol", "password-123");
    expect(cookie.startsWith("penguin_session=")).toBe(true);

    const api = apiClient(t.app, cookie);
    const me = (await (await api.get("/api/me")).json()) as MeResponse;
    expect(me.user.userId).toBe("carol");

    const logout = await api.post("/api/auth/logout");
    expect(logout.status).toBe(204);
    const after = await api.get("/api/me");
    expect(after.status).toBe(401);
  });

  it("self password change: old checked, new takes effect, initial flag cleared", async () => {
    const { cookie } = await provisionUser(t.app, "dave");
    const api = apiClient(t.app, cookie);

    const wrongOld = await api.put("/api/me/password", {
      oldPassword: "not-the-password",
      newPassword: "new-password-1",
    });
    expect(wrongOld.status).toBe(400);
    const tooShort = await api.put("/api/me/password", {
      oldPassword: "password-123",
      newPassword: "short",
    });
    expect(tooShort.status).toBe(400);

    const ok = await api.put("/api/me/password", {
      oldPassword: "password-123",
      newPassword: "new-password-1",
    });
    expect(ok.status).toBe(204);
    // After the password change, the current session remains valid and the initial-password flag is cleared.
    const me = (await (await api.get("/api/me")).json()) as MeResponse;
    expect(me.user.passwordIsInitial).toBe(false);
    // The old password is invalidated, and the new password can log in.
    const oldLogin = await t.app.request("/api/auth/login", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ userId: "dave", password: "password-123" }),
    });
    expect(oldLogin.status).toBe(401);
    await loginUser(t.app, "dave", "new-password-1");
  });

  it("write requests reject non-JSON Content-Type (CSRF defense)", async () => {
    const res = await t.app.request("/api/auth/login", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: "userId=a&password=b",
    });
    expect(res.status).toBe(415);
  });

  it("ui prefs read/write", async () => {
    const { cookie } = await provisionUser(t.app, "erin");
    const api = apiClient(t.app, cookie);
    const empty = (await (await api.get("/api/me/prefs")).json()) as { prefs: unknown };
    expect(empty.prefs).toEqual({});
    await api.put("/api/me/prefs", { theme: "dark", lastProjectId: "default_project" });
    const got = (await (await api.get("/api/me/prefs")).json()) as {
      prefs: { theme: string };
    };
    expect(got.prefs.theme).toBe("dark");
  });

  it("seedAdmin without an injected password generates penguin-<4 digits> and returns it", async () => {
    // Bypass the fixed test password: null matches the production default (random generation).
    const fresh = await createTestApp({ config: { seedAdminPassword: null } });
    try {
      expect(fresh.adminPassword).toMatch(/^penguin-\d{4}$/);
      // The returned password is the one that actually logs in.
      await loginUser(fresh.app, "admin", fresh.adminPassword);
      // Users exist now: re-seeding reports that nothing was seeded.
      expect(await fresh.deps.authService.seedAdmin()).toBeNull();
    } finally {
      await fresh.cleanup();
    }
  });

  it("seedAdmin honors the injected seedAdminPassword", async () => {
    const fresh = await createTestApp({ config: { seedAdminPassword: "penguin-7777" } });
    try {
      expect(fresh.adminPassword).toBe("penguin-7777");
      await loginUser(fresh.app, "admin", "penguin-7777");
    } finally {
      await fresh.cleanup();
    }
  });

  it("seedAdmin rejects an override below the password policy before creating the account", async () => {
    const root = await makeTempRoot();
    const deps = buildAppDeps({ ...testConfig(root), seedAdminPassword: "x" }, { log: () => {} });
    try {
      await expect(deps.authService.seedAdmin()).rejects.toThrow(/at least 8 characters/);
      // Rejected before any insert: no half-created privileged account to retry around.
      expect(deps.db.prepare("SELECT COUNT(*) AS n FROM users").get()?.n).toBe(0);
    } finally {
      deps.channels.dispose();
      deps.db.close();
      await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
    }
  });

  it("throttles login failures per username with exponential backoff and resets on success", async () => {
    let clock = Date.parse("2026-08-03T00:00:00Z");
    const fresh = await createTestApp({ now: () => new Date(clock) });
    try {
      const attempt = (password: string) =>
        fresh.app.request("/api/auth/login", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ userId: "admin", password }),
        });
      // Five free failures, and the sixth still reaches verification (backoff starts after it).
      for (let i = 0; i < 6; i++) expect((await attempt("wrong-password")).status).toBe(401);
      // Inside the 1s window: rejected without touching credentials — even the CORRECT password.
      const throttled = await attempt("wrong-password");
      expect(throttled.status).toBe(429);
      const body = (await throttled.json()) as { error: { code: string } };
      expect(body.error.code).toBe("too_many_attempts");
      expect((await attempt(TEST_ADMIN_PASSWORD)).status).toBe(429);
      // Past the window, the correct password signs in and clears the counter…
      clock += 1100;
      await loginUser(fresh.app, "admin", TEST_ADMIN_PASSWORD);
      // …so the next failure is an ordinary 401 again, not a 429.
      expect((await attempt("wrong-password")).status).toBe(401);
    } finally {
      await fresh.cleanup();
    }
  });

  it("throttles unknown usernames identically (no account-existence oracle)", async () => {
    let clock = Date.parse("2026-08-03T00:00:00Z");
    const fresh = await createTestApp({ now: () => new Date(clock) });
    try {
      const attempt = () =>
        fresh.app.request("/api/auth/login", {
          method: "POST",
          headers: { "content-type": "application/json" },
          body: JSON.stringify({ userId: "ghost", password: "whatever-123" }),
        });
      for (let i = 0; i < 6; i++) expect((await attempt()).status).toBe(401);
      expect((await attempt()).status).toBe(429);
      // The window expires on the same schedule as for real accounts.
      clock += 1100;
      expect((await attempt()).status).toBe(401);
    } finally {
      await fresh.cleanup();
    }
  });

  it("generateInitialAdminPassword matches penguin-<4 digits>", () => {
    for (let i = 0; i < 32; i++) {
      expect(generateInitialAdminPassword()).toMatch(/^penguin-\d{4}$/);
    }
  });

  it("PUT prefs shallow-merges without clobbering other writers' fields", async () => {
    const { cookie } = await provisionUser(t.app, "fred");
    const api = apiClient(t.app, cookie);
    // Simulate two independent writers: switching Project writes lastProjectId, and onboarding writes credentialGuideSeen.
    await api.put("/api/me/prefs", { lastProjectId: "p-1" });
    await api.put("/api/me/prefs", { credentialGuideSeen: true });
    const one = (await (await api.get("/api/me/prefs")).json()) as {
      prefs: { lastProjectId?: string; credentialGuideSeen?: boolean };
    };
    // The second write must not erase the fields from the first (a prior full replace would drop lastProjectId, causing onboarding to reappear repeatedly).
    expect(one.prefs).toEqual({ lastProjectId: "p-1", credentialGuideSeen: true });
    // Switch Project again: credentialGuideSeen is still present.
    await api.put("/api/me/prefs", { lastProjectId: "p-2" });
    const two = (await (await api.get("/api/me/prefs")).json()) as {
      prefs: { lastProjectId?: string; credentialGuideSeen?: boolean };
    };
    expect(two.prefs).toEqual({ lastProjectId: "p-2", credentialGuideSeen: true });
  });
});
