// Adversarial API probe (QA_PLAN B13 + B1). Find-mode: drives the real API as an
// authenticated user (user A) and observes responses — logs everything, and
// soft-asserts the two invariants that would be S1/S2 if violated:
//   1. no request produces a 500 (must be a structured 4xx instead), and
//   2. user A cannot read/modify user B's resources (data-isolation / IDOR).
// Runs on one project only; the DB (with user B + fixture) is built by prepare-db.
const fs = require('fs');
const path = require('path');
const { test, expect } = require('@playwright/test');
const { STORAGE_STATE } = require('./constants');

test.use({ storageState: STORAGE_STATE });

const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '.auth', 'fixture.json'), 'utf8'));

async function csrf(request) {
  const r = await request.get('/api/auth/csrf-token');
  return (await r.json()).token;
}

test('validation & error shapes — reject bad input, never a raw 500 (B13)', async ({ request }) => {
  const token = await csrf(request);
  const post = (data) => request.post('/api/bills', { headers: { 'x-csrf-token': token }, data });

  // [payload, expected: 'reject' (4xx) or 'accept' (2xx)]
  const cases = {
    'empty body': [{}, 'reject'],
    'missing name': [{ expected_amount: 10, due_day: 5 }, 'reject'],
    'negative amount': [{ name: 'Neg', expected_amount: -100, due_day: 5 }, 'reject'], // QA-B13-01
    'huge amount': [{ name: 'Huge', expected_amount: 1e15, due_day: 5 }, 'reject'], // QA-B13-01
    'non-numeric amount': [{ name: 'NaN', expected_amount: 'abc', due_day: 5 }, 'reject'], // QA-B13-01
    'due_day 0': [{ name: 'D0', expected_amount: 10, due_day: 0 }, 'reject'],
    'due_day 99': [{ name: 'D99', expected_amount: 10, due_day: 99 }, 'reject'],
    'due_day negative': [{ name: 'Dn', expected_amount: 10, due_day: -3 }, 'reject'],
    'zero amount (valid)': [{ name: 'Zero', expected_amount: 0, due_day: 5 }, 'accept'],
    'large valid amount': [{ name: 'Big', expected_amount: 9999999.99, due_day: 5 }, 'accept'],
    'xss name (stored inert)': [{ name: '<script>alert(1)</script>', expected_amount: 10, due_day: 5 }, 'accept'],
  };

  for (const [label, [data, expected]] of Object.entries(cases)) {
    const res = await post(data);
    const body = await res.text();
    console.log(`[validate] ${label.padEnd(24)} -> ${res.status()}  ${body.slice(0, 120)}`);
    expect.soft(res.status(), `"${label}" must never 500`).toBeLessThan(500);
    if (expected === 'reject') {
      expect.soft(res.status(), `"${label}" should be rejected (4xx)`).toBeGreaterThanOrEqual(400);
    } else {
      expect.soft(res.status(), `"${label}" should be accepted (2xx)`).toBeLessThan(300);
    }
  }
});

test('data-isolation — cannot touch another user\'s bill (B1/IDOR)', async ({ request }) => {
  const token = await csrf(request);
  const id = fixture.userBBillId;
  expect(id, 'fixture must provide a user B bill id').toBeTruthy();

  const get = await request.get(`/api/bills/${id}`);
  console.log(`[idor] GET    /api/bills/${id} -> ${get.status()}`);
  expect.soft(get.status(), 'GET user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);

  const put = await request.put(`/api/bills/${id}`, {
    headers: { 'x-csrf-token': token },
    data: { name: 'HACKED', expected_amount: 1, due_day: 1 },
  });
  console.log(`[idor] PUT    /api/bills/${id} -> ${put.status()}`);
  expect.soft(put.status(), 'PUT user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);

  const del = await request.delete(`/api/bills/${id}`, { headers: { 'x-csrf-token': token } });
  console.log(`[idor] DELETE /api/bills/${id} -> ${del.status()}`);
  expect.soft(del.status(), 'DELETE user B bill must be blocked (>=400)').toBeGreaterThanOrEqual(400);
});

test('authorization — a regular user is blocked from admin + status APIs (B1/B11)', async ({ request }) => {
  const token = await csrf(request);
  // Read endpoints → 403 (requireAdmin); user A is a regular 'user'.
  for (const url of ['/api/admin/users', '/api/admin/has-users', '/api/status', '/api/about-admin']) {
    const res = await request.get(url);
    console.log(`[authz] GET ${url} -> ${res.status()}`);
    expect.soft(res.status(), `${url} must be admin-only (403)`).toBe(403);
  }
  // Write endpoint → 403 too (not a silent success).
  const create = await request.post('/api/admin/users', {
    headers: { 'x-csrf-token': token },
    data: { username: 'sneaky_admin', password: 'password123', role: 'admin' },
  });
  console.log(`[authz] POST /api/admin/users -> ${create.status()}`);
  expect.soft(create.status(), 'creating a user as a non-admin must be 403').toBe(403);
});

test('bad / nonexistent id — structured, not a 500 (B13)', async ({ request }) => {
  for (const id of ['99999999', 'not-a-number', '0', '-1']) {
    const res = await request.get(`/api/bills/${id}`);
    const body = await res.text();
    console.log(`[errshape] GET /api/bills/${id} -> ${res.status()}  ${body.slice(0, 100)}`);
    expect.soft(res.status(), `id=${id} must not 500`).toBeLessThan(500);
  }
});

test('CSRF — a state-changing request without a token is rejected (B13)', async ({ request }) => {
  const res = await request.post('/api/bills', { data: { name: 'NoCSRF', expected_amount: 10, due_day: 5 } });
  console.log(`[csrf] POST /api/bills (no token) -> ${res.status()}`);
  expect.soft(res.status(), 'missing CSRF token must be rejected (403)').toBe(403);
});

test('reconcile — Tracker and Summary report the same month obligation (B5)', async ({ request }) => {
  const now = new Date();
  const q = `?year=${now.getFullYear()}&month=${now.getMonth() + 1}`;
  const tracker = await (await request.get(`/api/tracker${q}`)).json();
  const summary = await (await request.get(`/api/summary${q}`)).json();

  const trackerExpected = tracker?.summary?.total_expected ?? 0;
  // Summary lists each bill; sum its display_amount for the same month.
  const summaryExpected = (summary?.expenses ?? []).reduce(
    (s, e) => s + (Number(e.display_amount ?? e.expected_amount) || 0),
    0,
  );
  const round = (n) => Math.round(n * 100) / 100;
  console.log(`[recon] tracker.total_expected=$${round(trackerExpected)}  summary.sum=$${round(summaryExpected)}`);
  expect.soft(round(summaryExpected), 'Summary bill total must reconcile with Tracker (B5)').toBe(round(trackerExpected));
});

test('reconcile — Analytics expected line gates by occurrence like the Tracker (B5)', async ({ request }) => {
  const now = new Date();
  const y = now.getFullYear();
  const m = now.getMonth() + 1;
  const tracker = await (await request.get(`/api/tracker?year=${y}&month=${m}`)).json();
  const analytics = await (await request.get(`/api/analytics/summary?year=${y}&month=${m}&months=12`)).json();

  const key = `${y}-${String(m).padStart(2, '0')}`;
  const row = (analytics?.expected_vs_actual ?? []).find((r) => r.month === key);
  const trackerExpected = Math.round((tracker?.summary?.total_expected ?? 0) * 100) / 100;
  const analyticsExpected = Math.round((row?.expected ?? 0) * 100) / 100;
  console.log(`[recon] analytics.expected[${key}]=$${analyticsExpected}  tracker.total_expected=$${trackerExpected}`);
  expect.soft(analyticsExpected, 'Analytics expected must gate by occurrence (QA-B5-01 family)').toBe(trackerExpected);
});

test('seed demo data stores amounts in the correct unit — cents, not dollars (B9)', async ({ request }) => {
  // QA-B9-01: POST /api/user/seed-demo-data must produce realistic amounts. The
  // seed inserts dollars into the integer-cents expected_amount column (regression
  // since the v1.03 cents migration), so a seeded "$85" bill shows as $0.85.
  // We assert the *total* obligation for the seeded month is sane (>$100), which
  // fails while the 100x bug is present and passes once fixed.
  const now = new Date();
  const res = await request.get(`/api/tracker?year=${now.getFullYear()}&month=${now.getMonth() + 1}`);
  const body = await res.json();
  const totalExpected = body?.summary?.total_expected ?? 0;
  console.log(`[seed] tracker total_expected = $${totalExpected} (seeded month)`);
  expect
    .soft(totalExpected, 'seeded monthly bills should total > $100, not cents (QA-B9-01)')
    .toBeGreaterThan(100);
});
