// PATCH /apps/:id must reject route changes whose target row's
// slug is in BUILTIN_APP_SLUGS — those routes are hardcoded in the SPA
// (artifacts/tx-os/src/App.tsx) and silently break the launcher when an
// admin edits them. Other fields (icon, image, color, name, openMode,
// externalUrl, sortOrder) must still be editable, including for built-in
// apps. Apps whose slug is not built-in keep accepting route changes.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
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 adminId;
let adminUsername;
let adminCookie;
let userId;
let userUsername;
let userCookie;
let testPermissionId;
const createdAppIds = [];

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");
  return setCookie
    .split(",")
    .map((c) => c.split(";")[0].trim())
    .find((c) => c.startsWith("connect.sid="));
}

async function createAppRow({ slug, route }) {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  const realSlug = slug ?? `customapp_${stamp}`;
  const realRoute = route ?? `/custom-${stamp}`;
  const { rows } = await pool.query(
    `INSERT INTO apps (slug, name_ar, name_en, route, is_active, sort_order)
     VALUES ($1, $2, $3, $4, TRUE, 0) RETURNING id`,
    [realSlug, realSlug, realSlug, realRoute],
  );
  createdAppIds.push(rows[0].id);
  return { id: rows[0].id, slug: realSlug, route: realRoute };
}

before(async () => {
  const stamp = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
  adminUsername = `admin_lock_${stamp}`;
  const { rows } = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, $1, 'en', TRUE) RETURNING id`,
    [adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  adminId = rows[0].id;
  await pool.query(
    `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'admin'`,
    [adminId],
  );
  adminCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);

  // Non-admin user used to verify GET /apps/:id visibility gating for
  // the new external-iframe / embedded flow.
  userUsername = `user_lock_${stamp}`;
  const userRow = await pool.query(
    `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
     VALUES ($1, $2, $3, $1, 'en', TRUE) RETURNING id`,
    [userUsername, `${userUsername}@example.com`, TEST_PASSWORD_HASH],
  );
  userId = userRow.rows[0].id;
  userCookie = await loginAndGetCookie(userUsername, TEST_PASSWORD);

  const permRow = await pool.query(
    `INSERT INTO permissions (name, description_en) VALUES ($1, 'lock-test')
     ON CONFLICT (name) DO UPDATE SET description_en = EXCLUDED.description_en
     RETURNING id`,
    [`apps_lock_visibility_${stamp}`],
  );
  testPermissionId = permRow.rows[0].id;
});

after(async () => {
  if (createdAppIds.length > 0) {
    await pool.query(`DELETE FROM app_permissions WHERE app_id = ANY($1::int[])`, [createdAppIds]);
    await pool.query(`DELETE FROM apps WHERE id = ANY($1::int[])`, [createdAppIds]);
  }
  if (testPermissionId) {
    await pool.query(`DELETE FROM permissions WHERE id = $1`, [testPermissionId]);
  }
  if (adminId) {
    await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminId]);
    await pool.query(`DELETE FROM users WHERE id = $1`, [adminId]);
  }
  if (userId) {
    await pool.query(`DELETE FROM users WHERE id = $1`, [userId]);
  }
  await pool.end();
});

test("GET /apps/:id 404s for users who cannot see the app (visibility gate)", async () => {
  // Without this gate, any logged-in user could fetch externalUrl for
  // restricted apps via /embedded/:id and load them in an iframe.
  const created = await createAppRow({});
  await pool.query(
    `INSERT INTO app_permissions (app_id, permission_id) VALUES ($1, $2)`,
    [created.id, testPermissionId],
  );

  // Admin can still read it (admin role bypasses permission gates).
  const adminRes = await fetch(`${API_BASE}/api/apps/${created.id}`, {
    headers: { Cookie: adminCookie },
  });
  assert.equal(adminRes.status, 200, "admin must still see the app");

  // Non-admin without the gating permission must get 404 (not 200, and
  // not 403, so we don't leak existence of restricted ids).
  const userRes = await fetch(`${API_BASE}/api/apps/${created.id}`, {
    headers: { Cookie: userCookie },
  });
  assert.equal(userRes.status, 404, `expected 404 for unauthorized user, got ${userRes.status}`);
});

test("PATCH /apps/:id rejects route changes for built-in slugs with code=builtin_route_locked", async () => {
  // Pick a built-in slug. We INSERT a row with that exact slug — the live
  // DB seeds them too, but using a freshly inserted row keeps the test
  // self-contained and avoids touching the seeded /admin/etc apps.
  // First clean any stray prior test row for this slug; the schema has a
  // UNIQUE constraint on slug.
  const slug = "executive-meetings";
  const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]);
  let appId;
  let originalRoute;
  if (original.rowCount > 0) {
    appId = original.rows[0].id;
    originalRoute = original.rows[0].route;
  } else {
    const created = await createAppRow({ slug, route: "/executive-meetings" });
    appId = created.id;
    originalRoute = created.route;
  }

  const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Cookie: adminCookie,
    },
    body: JSON.stringify({ route: "/something-else" }),
  });
  assert.equal(res.status, 400, `expected 400, got ${res.status}`);
  const body = await res.json();
  assert.equal(body.code, "builtin_route_locked");

  // Confirm the row was NOT mutated.
  const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [appId]);
  assert.equal(after.rows[0].route, originalRoute);
});

test("PATCH /apps/:id allows non-route updates on built-in apps", async () => {
  const slug = "notes";
  const original = await pool.query(`SELECT id, route, icon_name FROM apps WHERE slug = $1`, [slug]);
  let appId;
  let originalRoute;
  let originalIcon;
  if (original.rowCount > 0) {
    appId = original.rows[0].id;
    originalRoute = original.rows[0].route;
    originalIcon = original.rows[0].icon_name;
  } else {
    const created = await createAppRow({ slug, route: "/notes" });
    appId = created.id;
    originalRoute = created.route;
    originalIcon = "Grid";
  }

  // Change icon + openMode but keep route untouched (omit it).
  const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Cookie: adminCookie,
    },
    body: JSON.stringify({ iconName: "Sparkles", openMode: "internal" }),
  });
  assert.equal(res.status, 200, `expected 200, got ${res.status}`);

  const after = await pool.query(
    `SELECT route, icon_name, open_mode FROM apps WHERE id = $1`,
    [appId],
  );
  assert.equal(after.rows[0].route, originalRoute, "route must not change");
  assert.equal(after.rows[0].icon_name, "Sparkles");
  assert.equal(after.rows[0].open_mode, "internal");

  // Restore icon so the test is idempotent for re-runs.
  await pool.query(`UPDATE apps SET icon_name = $1 WHERE id = $2`, [originalIcon, appId]);
});

test("PATCH /apps/:id allows route changes on non-built-in apps", async () => {
  const created = await createAppRow({});
  const newRoute = `${created.route}_v2`;
  const res = await fetch(`${API_BASE}/api/apps/${created.id}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Cookie: adminCookie,
    },
    body: JSON.stringify({ route: newRoute }),
  });
  assert.equal(res.status, 200, `expected 200, got ${res.status}`);
  const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [created.id]);
  assert.equal(after.rows[0].route, newRoute);
});

test("PATCH /apps/:id rejects slug changes for built-in apps with code=builtin_slug_locked", async () => {
  // Anti-bypass: without this, an admin could rename the slug to a
  // non-built-in value (allowed) and then change the route in a second
  // request (now the previous slug looks non-built-in and would pass).
  const slug = "executive-meetings";
  const original = await pool.query(`SELECT id, slug, route FROM apps WHERE slug = $1`, [slug]);
  let appId;
  let originalSlug;
  let originalRoute;
  if (original.rowCount > 0) {
    appId = original.rows[0].id;
    originalSlug = original.rows[0].slug;
    originalRoute = original.rows[0].route;
  } else {
    const created = await createAppRow({ slug, route: "/executive-meetings" });
    appId = created.id;
    originalSlug = slug;
    originalRoute = created.route;
  }

  const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ slug: `renamed_${Date.now()}` }),
  });
  assert.equal(res.status, 400, `expected 400, got ${res.status}`);
  const body = await res.json();
  assert.equal(body.code, "builtin_slug_locked");

  const after = await pool.query(`SELECT slug, route FROM apps WHERE id = $1`, [appId]);
  assert.equal(after.rows[0].slug, originalSlug, "slug must not change");
  assert.equal(after.rows[0].route, originalRoute, "route must not change");
});

test("PATCH /apps/:id rejects route changes for seeded built-ins (calendar)", async () => {
  // calendar/documents have no hardcoded SPA <Route> but are seeded
  // canonical apps; admins must not be able to repurpose their slug or
  // path or links/audits/deep-links break.
  const slug = "calendar";
  const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]);
  if (original.rowCount === 0) return;
  const appId = original.rows[0].id;
  const originalRoute = original.rows[0].route;

  const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ route: `/calendar-renamed-${Date.now()}` }),
  });
  assert.equal(res.status, 400);
  const body = await res.json();
  assert.equal(body.code, "builtin_route_locked");
  const after = await pool.query(`SELECT route FROM apps WHERE id = $1`, [appId]);
  assert.equal(after.rows[0].route, originalRoute);
});

test("POST /apps rejects externalUrl with non-http(s) scheme (XSS guard, create path)", async () => {
  const res = await fetch(`${API_BASE}/api/apps`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({
      slug: `xss-create-${Date.now()}`,
      nameAr: "ت",
      nameEn: "T",
      iconName: "Grid2X2",
      route: `/xss-${Date.now()}`,
      color: "#000000",
      sortOrder: 0,
      externalUrl: "javascript:alert(1)",
      openMode: "external_tab",
    }),
  });
  assert.equal(res.status, 400);
  const body = await res.json();
  assert.equal(body.code, "invalid_external_url");
});

test("PATCH /apps/:id rejects externalUrl with non-http(s) scheme (XSS guard)", async () => {
  // javascript: in an iframe src or window.open target = stored XSS for
  // every user; data: / file: are similarly dangerous. Server must reject.
  const created = await createAppRow({});
  for (const badUrl of ["javascript:alert(1)", "data:text/html,<script>1</script>", "file:///etc/passwd"]) {
    const res = await fetch(`${API_BASE}/api/apps/${created.id}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json", Cookie: adminCookie },
      body: JSON.stringify({ externalUrl: badUrl, openMode: "external_tab" }),
    });
    assert.equal(res.status, 400, `expected 400 for ${badUrl}, got ${res.status}`);
    const body = await res.json();
    assert.equal(body.code, "invalid_external_url", `wrong code for ${badUrl}`);
  }
  // https:// is accepted.
  const ok = await fetch(`${API_BASE}/api/apps/${created.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Cookie: adminCookie },
    body: JSON.stringify({ externalUrl: "https://example.com/ok", openMode: "external_tab" }),
  });
  assert.equal(ok.status, 200);
});

test("PATCH /apps/:id with same route on built-in slug is a no-op success", async () => {
  // Sending route equal to current value should NOT be rejected, since
  // it isn't actually changing the path.
  const slug = "executive-meetings";
  const original = await pool.query(`SELECT id, route FROM apps WHERE slug = $1`, [slug]);
  let appId;
  let route;
  if (original.rowCount > 0) {
    appId = original.rows[0].id;
    route = original.rows[0].route;
  } else {
    const created = await createAppRow({ slug, route: "/executive-meetings" });
    appId = created.id;
    route = created.route;
  }

  const res = await fetch(`${API_BASE}/api/apps/${appId}`, {
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      Cookie: adminCookie,
    },
    body: JSON.stringify({ route }),
  });
  assert.equal(res.status, 200, `expected 200, got ${res.status}`);
});
