import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import pg from "pg";

const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
  throw new Error("DATABASE_URL must be set to run these tests");
}

const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
  "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";

const pool = new pg.Pool({ connectionString: DATABASE_URL });

let testUserId;
let testUsername;
let validAppId;
let validAppRoute;
let badAppId;
let sessionCookie;

async function loginAndGetCookie(username, password) {
  const res = await fetch(`${API_BASE}/api/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username, password }),
  });
  assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
  const setCookie = res.headers.get("set-cookie");
  assert.ok(setCookie, "expected Set-Cookie header from login");
  const sid = setCookie.split(",").map((c) => c.split(";")[0].trim()).find((c) =>
    c.startsWith("connect.sid="),
  );
  assert.ok(sid, "expected connect.sid cookie");
  return sid;
}

async function countOpens({ userId, appId } = {}) {
  let query = "SELECT COUNT(*)::int AS c FROM app_opens WHERE 1=1";
  const params = [];
  if (userId !== undefined) {
    params.push(userId);
    query += ` AND user_id = $${params.length}`;
  }
  if (appId !== undefined) {
    params.push(appId);
    query += ` AND app_id = $${params.length}`;
  }
  const { rows } = await pool.query(query, params);
  return rows[0].c;
}

before(async () => {
  testUsername = `open_test_${Date.now().toString(36)}_${Math.random()
    .toString(36)
    .slice(2, 6)}`;
  const userRes = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, 'Open Test', 'en', true) RETURNING id`,
    [testUsername, `${testUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  testUserId = userRes.rows[0].id;

  await pool.query(
    `INSERT INTO user_roles (user_id, role_id)
     SELECT $1, id FROM roles WHERE name = 'user'`,
    [testUserId],
  );

  const appRes = await pool.query(
    `SELECT id, route FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`,
  );
  assert.ok(appRes.rows[0], "no active app found to test against");
  validAppId = appRes.rows[0].id;
  validAppRoute = appRes.rows[0].route;

  const maxRes = await pool.query(`SELECT COALESCE(MAX(id), 0) AS m FROM apps`);
  badAppId = Number(maxRes.rows[0].m) + 100000;

  sessionCookie = await loginAndGetCookie(testUsername, TEST_PASSWORD);
});

after(async () => {
  if (testUserId !== undefined) {
    await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [testUserId]);
    await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [testUserId]);
    await pool.query(`DELETE FROM users WHERE id = $1`, [testUserId]);
  }
  await pool.end();
});

test("happy path: authenticated POST inserts a row and returns 204", async () => {
  const before = await countOpens({ userId: testUserId, appId: validAppId });
  const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, {
    method: "POST",
    headers: { Cookie: sessionCookie },
  });
  assert.equal(res.status, 204);
  const after = await countOpens({ userId: testUserId, appId: validAppId });
  assert.equal(after, before + 1, "app_opens row should be inserted");
});

test("unauthenticated POST returns 401 and inserts no row", async () => {
  const before = await countOpens({ appId: validAppId });
  const res = await fetch(`${API_BASE}/api/apps/${validAppId}/open`, {
    method: "POST",
  });
  assert.equal(res.status, 401);
  const after = await countOpens({ appId: validAppId });
  assert.equal(after, before, "no row should be inserted on 401");
});

test("authenticated POST to non-existent app id returns 404 and inserts no row", async () => {
  const before = await countOpens({ appId: badAppId });
  const res = await fetch(`${API_BASE}/api/apps/${badAppId}/open`, {
    method: "POST",
    headers: { Cookie: sessionCookie },
  });
  assert.equal(res.status, 404);
  const after = await countOpens({ appId: badAppId });
  assert.equal(after, before);
  assert.equal(after, 0);
});

// Simulates a slow network where the client navigates away (and the response
// is never consumed) before the server finishes. The fire-and-forget POST in
// home.tsx uses keepalive so the request reaches the server; the server-side
// handler awaits the DB insert before responding, so the row must still be
// recorded even if the client aborts before reading the response.
test("slow network: row is still inserted when the client aborts before reading the response", async () => {
  const before = await countOpens({ userId: testUserId, appId: validAppId });

  await new Promise((resolve, reject) => {
    const url = new URL(`${API_BASE}/api/apps/${validAppId}/open`);
    const req = http.request(
      {
        method: "POST",
        hostname: url.hostname,
        port: url.port,
        path: url.pathname,
        headers: {
          Cookie: sessionCookie,
          "Content-Length": "0",
        },
      },
      (res) => {
        // If we get a response back quickly, just consume and resolve.
        res.resume();
        res.on("end", resolve);
      },
    );
    req.on("error", (err) => {
      // ECONNRESET from our destroy() below is expected.
      if (err && (err.code === "ECONNRESET" || err.code === "ECONNABORTED")) {
        resolve();
      } else {
        reject(err);
      }
    });
    req.end();
    // Abort the connection ~50ms after sending so the client never reads the
    // response, mimicking a navigation-aborted keepalive request.
    setTimeout(() => req.destroy(), 50);
  });

  // Give the server time to finish its insert even though we abandoned the
  // socket on the client side.
  await new Promise((r) => setTimeout(r, 1500));

  const after = await countOpens({ userId: testUserId, appId: validAppId });
  assert.equal(
    after,
    before + 1,
    "row should be inserted server-side even when the client aborts before reading the response",
  );
});
