// #495 — file download authorization (P0 staged hotfix, PR #500).
//
// Before this fix, /api/files/:file_id only checked authentication.
// Any authenticated principal could download any file cross-network by
// guessing / obtaining a file_id.
//
// This suite spawns a real Bun.serve hub in-process and validates the
// allow-list (owner + admin + legacy-master + null-owner-DEV_OPEN).
// Same-network non-owner is EXPLICITLY out of scope in this stage —
// see PR body access matrix + follow-up #503. That "same-network peer
// returns 404" is an intentional carve-out here, not a bug.
//
// The DEV_OPEN branch is verified in a sibling test file
// (file-download-authz-dev-open.test.ts) because DEV_OPEN is captured
// at server.ts module-load time and can't be flipped mid-suite.
//
// Skip discipline: master-token tests use `test.skipIf(!masterTokenActive)`
// so aggregate `bun test src/` reports "skipped" (not "passed") when
// the loaded server module's frozen AUTH_TOKEN binding predates our
// env setup. `if (!x) return` was previously used and Bun records
// that as PASS — which is exactly the "skip-as-pass" trap that let
// an earlier round claim coverage it never had. (See project memory
// `feedback_skip_via_early_return_shows_as_pass`.)

import { describe, expect, test, beforeAll, afterAll } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";

const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-495-db-")) + "/commhub.db";
const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-fs-"));
const MASTER_TOKEN = process.env.COMMHUB_AUTH_TOKEN ?? `master-495-${Date.now()}-${Math.floor(Math.random() * 100000)}`;

// Set env BEFORE importing anything that pulls in db-adapter.ts (which
// refuses to open a default DB under NODE_ENV=test) or server.ts (which
// captures DEV_OPEN + AUTH_TOKEN at module load).
process.env.COMMHUB_DB = SERVER_DB;
process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
process.env.HOST = "127.0.0.1";
process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
delete process.env.COMMHUB_DEV_OPEN;

const { register, addNetworkMember, getUserAllNetworks } = await import("./auth.js");
const { db } = await import("./db.js");

// Seed accounts. register()'s "first-user-is-admin" is not reliable in
// aggregate (earlier test files may seed users), so we promote via SQL.
const adminName = `admin_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const rAdmin = register(adminName, "BootstrapPw123Aa!", undefined, "seed");
if (!rAdmin.ok) throw new Error(`admin register failed: ${rAdmin.error}`);
const adminToken = rAdmin.token!;
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rAdmin.user!.user_id]);

const nameA = `useraaa_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
const userAToken = rA.token!;
const userAUserId = rA.user!.user_id;
const userANetworkId = rA.network_id!;

// #503 — userA joins userB's network below, which makes userA a
// SAME-network peer. Cross-network denial needs a principal who is in
// neither, so userC exists purely to be an outsider.
const nameC = `userccc_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const rC = register(nameC, "BootstrapPw123Aa!", undefined, "userC");
if (!rC.ok) throw new Error(`userC register failed: ${rC.error}`);
const userCToken = rC.token!;
const userCUserId = rC.user!.user_id;

const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`);
const userBToken = rB.token!;
const userBUserId = rB.user!.user_id;
const userBNetworkToken = rB.network_token!;
const userBNetworkId = getUserAllNetworks(userBUserId)[0]?.network_id!;
if (!userBNetworkId) throw new Error("userB default network not found");

// #500 CR2 DEV_OPEN 假门 fix — assert userB is NOT admin at fixture
// setup time. If fixture ordering ever changes (e.g. auth.ts flips
// "first user auto-admin" logic, or an earlier test file promotes
// userB), the assertion catches it before Test A/B can silently
// short-circuit through the admin branch of authorizeFileDownload
// and hide a broken DEV_OPEN gate. Independent审员 caught this
// class of假门 in the prior CR round — the assertion writes the
// "userB is non-admin" invariant into the test itself so a future
// fixture drift cannot re-open the hole.
{
  const userBRoleRow = db.get<{ role: string }>(
    "SELECT role FROM users WHERE user_id = ?1",
    userBUserId,
  );
  if (!userBRoleRow) throw new Error("userB not found in users table after register");
  if (userBRoleRow.role === "admin") {
    throw new Error(
      `[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` +
      `(role=${userBRoleRow.role}). Refusing to run: the DEV_OPEN and ` +
      `cross-owner tests would short-circuit through the admin allow-list ` +
      `branch and validate nothing.`,
    );
  }
}

// Add userA as a member of userB's default network. This is what makes
// the "same-network non-owner" test cell meaningful — without it, that
// test would only prove cross-network denial, which is a weaker claim.
const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId);
if (!addMember.ok) throw new Error(`add userA to userB network failed: ${addMember.error}`);

const { bootServer } = await import("./server.js");
const server = bootServer({ port: 0, hostname: "127.0.0.1" });
const BASE = `http://127.0.0.1:${server.port}`;
await new Promise((r) => setTimeout(r, 100));

// Probe the loaded server's frozen AUTH_TOKEN binding. If master token
// works we can exercise the legacy branch; if not (aggregate load-order
// effect), `test.skipIf` reports "skipped" (NOT "passed").
const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, {
  headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
});
const masterTokenActive = _probeMaster.status === 404;

// Probe the loaded server's frozen DEV_OPEN binding. This file is
// meant to run against a DEV_OPEN=false server. In aggregate mode,
// if file-download-authz-dev-open.test.ts loaded server.ts first
// with DEV_OPEN=true, the singleton wins — we can't flip it. Skip
// the whole production-mode suite via `describe.skipIf` (NOT
// early-return in each test body) so the runner reports "skipped".
async function _uploadProbe(): Promise<number> {
  const form = new FormData();
  form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
  const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
  return r.status;
}
const devOpenSuspected = (await _uploadProbe()) === 200;
const isProdMode = !devOpenSuspected;

afterAll(() => {
  try { server?.stop?.(true); } catch {}
  try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
  try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
  delete process.env.COMMHUB_AUTH_TOKEN;
});

// #503 — a utok_ holder in more than one network must name the target
// network; the server refuses to guess ("first network" is not assumed).
// Callers using a single-network token can still omit it.
async function uploadAs(token: string, content: Uint8Array, filename: string, networkId?: string): Promise<string> {
  const form = new FormData();
  form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
  const url = networkId
    ? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}`
    : `${BASE}/api/upload`;
  const res = await fetch(url, {
    method: "POST",
    body: form,
    headers: { Authorization: `Bearer ${token}` },
  });
  expect(res.status).toBe(200);
  const body: any = await res.json();
  expect(typeof body.file_id).toBe("string");
  return body.file_id;
}

async function downloadAs(token: string | null, fileId: string): Promise<Response> {
  const headers: Record<string, string> = {};
  if (token) headers.Authorization = `Bearer ${token}`;
  return fetch(`${BASE}/api/files/${fileId}`, { headers });
}

describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (owner-only staged)", () => {
  let userBFileId = "";

  beforeAll(async () => {
    userBFileId = await uploadAs(userBToken, new TextEncoder().encode("secret-from-B"), "b-secret.txt");
    expect(userBFileId.length).toBe(32);
  });

  test("owner (userB) via utok_ downloads own file → 200 (normal path)", async () => {
    const res = await downloadAs(userBToken, userBFileId);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("secret-from-B");
  });

  test("owner (userB) via ntok_ downloads own file → 200 (agent self-upload+self-download)", async () => {
    const agentFileId = await uploadAs(userBNetworkToken, new TextEncoder().encode("agent-blob"), "agent.bin");
    const res = await downloadAs(userBNetworkToken, agentFileId);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("agent-blob");
  });

  test("🔴 cross-network non-owner (userC) downloads userB's file → 404 (was 200 pre-#495)", async () => {
    // Precondition — userC is in NEITHER of userB's networks, so the 404
    // is cross-network denial. This row used to use userA, but #503 made
    // userA a legitimate same-network reader; keeping userA here would
    // have turned #495's guarantee into a test that asserts nothing.
    const rows = db.all<{ network_id: string }>(
      "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
      userCUserId, userBNetworkId,
    );
    expect(rows.length).toBe(0);
    const res = await downloadAs(userCToken, userBFileId);
    expect(res.status).toBe(404);
    const body: any = await res.json();
    expect(body.error).toBe("not_found");
  });

  test("🔴 same-network non-owner (userA is a member of userB's network) → 200 (#503 lifted the #495 staged carve-out)", async () => {
    // Precondition — userA really is a member of userB's default network
    // (added in module setup), so the 200 represents "same-network peer
    // allowed" rather than an owner match or an admin bypass.
    const rows = db.all<{ network_id: string }>(
      "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
      userAUserId, userBNetworkId,
    );
    expect(rows.length).toBe(1);
    expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId)?.role)
      .not.toBe("admin");
    const res = await downloadAs(userAToken, userBFileId);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("secret-from-B");
  });

  test("admin caller downloads any file → 200 (operational access preserved)", async () => {
    const res = await downloadAs(adminToken, userBFileId);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("secret-from-B");
  });

  test.skipIf(!masterTokenActive)("legacy master AUTH_TOKEN downloads any file → 200 (single-tenant deployments)", async () => {
    const res = await downloadAs(MASTER_TOKEN, userBFileId);
    expect(res.status).toBe(200);
    expect(await res.text()).toBe("secret-from-B");
  });

  test("anonymous request → 401 (auth still required)", async () => {
    const res = await downloadAs(null, userBFileId);
    expect(res.status).toBe(401);
  });

  test("non-owner request for an UNKNOWN file_id → 404 with same shape as owner-denied (no enumeration signal)", async () => {
    const res = await downloadAs(userAToken, "0123456789abcdef0123456789abcdef");
    expect(res.status).toBe(404);
    const body: any = await res.json();
    expect(body.error).toBe("not_found");
  });
});

// #500 CR2 ① — HEAD verb explicit authz coverage. Pre-CR2, HEAD fell
// through to a fallback 200 page; body was byte-identical to unknown
// file_id so no oracle formed by accident, but it was coincidence, not
// a gate. This suite asserts HEAD routes through authorizeFileDownload
// per RFC 9110 §9.3.2: same status/headers as GET, body omitted.
async function headAs(token: string | null, fileId: string): Promise<Response> {
  const headers: Record<string, string> = {};
  if (token) headers.Authorization = `Bearer ${token}`;
  return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
}

describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorization (parity with GET)", () => {
  let userBFileId = "";

  beforeAll(async () => {
    userBFileId = await uploadAs(userBToken, new TextEncoder().encode("head-scenario-payload"), "b-head.txt");
  });

  test("owner (userB) HEAD own file → 200 + content-length header (body omitted per HEAD)", async () => {
    const res = await headAs(userBToken, userBFileId);
    expect(res.status).toBe(200);
    expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
    expect(res.headers.get("content-disposition")).toContain("attachment");
    expect(res.headers.get("x-content-type-options")).toBe("nosniff");
  });

  test("🔴 cross-network HEAD (userC HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
    // userC, not userA — #503 makes userA a same-network reader, so the
    // GET/HEAD parity claim needs a principal who is genuinely denied.
    const res = await headAs(userCToken, userBFileId);
    expect(res.status).toBe(404);
    // 404 must originate from authorizeFileDownload, not the fallback
    // page. Body is application/json ({"ok":false,"error":"not_found"})
    // — check Content-Type so a future fallback rewrite (e.g. adding
    // "requested resource type: X") does not silently form an oracle
    // while HEAD still returns 200 from that page.
    expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
  });

  test("HEAD unknown file_id → 404 with same shape as cross-owner (no enumeration signal)", async () => {
    const res = await headAs(userAToken, "0123456789abcdef0123456789abcdef");
    expect(res.status).toBe(404);
    expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
  });

  test("HEAD anonymous → 401 (auth still required, symmetric with GET)", async () => {
    const res = await headAs(null, userBFileId);
    expect(res.status).toBe(401);
  });

  test("admin HEAD any file → 200 (operational access preserved on HEAD)", async () => {
    const res = await headAs(adminToken, userBFileId);
    expect(res.status).toBe(200);
    expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
  });
});

// #500 CR2 ④ — Upload owner persistence. Prove that new authenticated
// uploads always write a truthy owner_id to the on-disk index entry.
// If the upload handler ever regresses to writing owner_id=null in
// requireAuth-guarded paths, D1 turns red — the invariant that
// "production uploads never produce null-owner blobs" is documented
// in the authorizeFileDownload comment and this test enforces it.
describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => {
  test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => {
    // userA belongs to two networks (own default + userB's), so #503
    // requires the target network to be named explicitly.
    const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin", userANetworkId);
    const { readFileSync } = await import("fs");
    const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
    expect(existsSync(entryPath)).toBe(true);
    const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
    expect(entry.owner_id).not.toBeNull();
    expect(entry.owner_id).toBe(userAUserId);
    expect(entry.owner).toBe(nameA); // owner username stays consistent with the token that uploaded
  });

  test("D1b: userB authenticated upload via utok_ → owner_id === userB.userId", async () => {
    const fileId = await uploadAs(userBToken, new TextEncoder().encode("owner-persistence-check-b"), "d1b.bin");
    const { readFileSync } = await import("fs");
    const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
    expect(existsSync(entryPath)).toBe(true);
    const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
    expect(entry.owner_id).not.toBeNull();
    expect(entry.owner_id).toBe(userBUserId);
  });
});

describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN=off, non-admin/non-legacy)", () => {
  let nullOwnerFileId = "";

  beforeAll(async () => {
    // Simulate a legacy null-owner index entry by uploading normally
    // then rewriting the on-disk index to owner_id=null. This precisely
    // reproduces the shape a DEV_OPEN or legacy master upload would
    // create, without needing to spin up a second server in a
    // different mode.
    nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt", userANetworkId);
    const { readFileSync: rfs, writeFileSync: wfs } = await import("fs");
    const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`);
    if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`);
    const entry = JSON.parse(rfs(entryFile, "utf-8"));
    entry.owner = null;
    entry.owner_id = null;
    // #503 — a true legacy entry predates network attribution entirely.
    // Deleting the key (rather than nulling it) matches what the writer
    // emits when there is no attribution, so this fixture exercises the
    // legacy compatibility branch instead of the network branch.
    delete entry.network_id;
    wfs(entryFile, JSON.stringify(entry, null, 2));
  });

  test.skipIf(!masterTokenActive)("null-owner + legacy master → 200 (RFC-001 read-only backward-compat)", async () => {
    const res = await downloadAs(MASTER_TOKEN, nullOwnerFileId);
    expect(res.status).toBe(200);
  });

  test("null-owner + admin utok_ → 200 (operational access preserved)", async () => {
    const res = await downloadAs(adminToken, nullOwnerFileId);
    expect(res.status).toBe(200);
  });

  test("🔴 Test A (production default) — null-owner + normal user + DEV_OPEN off → 404 fail-closed", async () => {
    // Runtime invariant: this test file explicitly deletes COMMHUB_DEV_OPEN
    // at module setup, so the DEV_OPEN branch in authorizeFileDownload
    // MUST NOT execute here. If someone weakens the guard so that
    // null-owner is granted even without DEV_OPEN, this assertion turns
    // red. That IS the mutation self-check for the production carve-out.
    expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined();
    const res = await downloadAs(userAToken, nullOwnerFileId);
    expect(res.status).toBe(404);
    const body: any = await res.json();
    expect(body.error).toBe("not_found");
  });
});
