#!/usr/bin/env node
/**
 * e2e-test.mjs — End-to-end tests for the LibNovel v3 stack.
 *
 * Hits live services via https://localhost (self-signed cert, TLS verify skipped).
 * Requires: Node 18+ (built-in fetch with TLS options via --experimental-fetch or
 * native in Node 21+). Run with: node --experimental-vm-modules scripts/e2e-test.mjs
 * or simply: node scripts/e2e-test.mjs
 *
 * Services tested:
 *   - Caddy / UI    https://localhost
 *   - Go backend    via UI proxy routes
 *   - PocketBase    via UI server-side (indirect)
 *
 * Usage:
 *   node scripts/e2e-test.mjs
 *   node scripts/e2e-test.mjs --verbose
 */

import { createServer } from 'node:https';
import { request as httpRequest } from 'node:https';
import { URL } from 'node:url';

const BASE = 'https://localhost';
const VERBOSE = process.argv.includes('--verbose');

// ─── Helpers ──────────────────────────────────────────────────────────────────

let passed = 0;
let failed = 0;
const failures = [];

function log(...args) {
  if (VERBOSE) console.log(...args);
}

function pass(name) {
  passed++;
  console.log(`  ✓  ${name}`);
}

function fail(name, reason) {
  failed++;
  const msg = `  ✗  ${name}: ${reason}`;
  console.log(msg);
  failures.push({ name, reason });
}

/**
 * fetch() that ignores TLS certificate errors (self-signed cert on localhost).
 */
async function get(path, { headers = {}, followRedirects = false } = {}) {
  const url = path.startsWith('http') ? path : `${BASE}${path}`;
  const res = await fetch(url, {
    redirect: followRedirects ? 'follow' : 'manual',
    headers,
    // Node 18/19 uses undici which respects NODE_TLS_REJECT_UNAUTHORIZED
  });
  return res;
}

async function post(path, body, { headers = {}, cookie = '' } = {}) {
  const url = path.startsWith('http') ? path : `${BASE}${path}`;
  const res = await fetch(url, {
    method: 'POST',
    redirect: 'manual',
    headers: {
      'Content-Type': 'application/json',
      ...(cookie ? { Cookie: cookie } : {}),
      ...headers,
    },
    body: JSON.stringify(body),
  });
  return res;
}

async function del(path, { cookie = '' } = {}) {
  const url = path.startsWith('http') ? path : `${BASE}${path}`;
  const res = await fetch(url, {
    method: 'DELETE',
    redirect: 'manual',
    headers: cookie ? { Cookie: cookie } : {},
  });
  return res;
}

/** Extract Set-Cookie header value(s) as a single cookie string. */
function extractCookies(res) {
  const raw = res.headers.getSetCookie?.() ?? [];
  return raw.map((c) => c.split(';')[0]).join('; ');
}

async function assert(name, fn) {
  try {
    await fn();
    pass(name);
  } catch (e) {
    fail(name, e.message);
  }
}

function expect(val, label) {
  return {
    toBe(expected) {
      if (val !== expected) throw new Error(`${label}: expected ${expected}, got ${val}`);
    },
    toBeOneOf(...options) {
      if (!options.includes(val)) throw new Error(`${label}: expected one of [${options.join(', ')}], got ${val}`);
    },
    toBeOk() {
      if (!val) throw new Error(`${label} was falsy`);
    },
    toContainKey(key) {
      if (!(key in val)) throw new Error(`${label}: missing key "${key}"`);
    },
    toBeArray() {
      if (!Array.isArray(val)) throw new Error(`${label}: expected array, got ${typeof val}`);
    },
    toBeAbove(n) {
      if (!(val > n)) throw new Error(`${label}: expected > ${n}, got ${val}`);
    },
  };
}

// ─── Test suite ───────────────────────────────────────────────────────────────

process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';

// Pick a known book slug from the database (first available)
let TEST_SLUG = null;

console.log('\nLibNovel v3 — End-to-End Tests');
console.log('================================\n');

// ── 1. Health checks ──────────────────────────────────────────────────────────
console.log('1. Health checks');

await assert('UI health endpoint returns 200', async () => {
  const res = await get('/health');
  expect(res.status, 'status').toBe(200);
});

await assert('Home page returns 200', async () => {
  const res = await get('/', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
  const html = await res.text();
  expect(html.includes('<html') || html.includes('<!DOCTYPE'), 'has html').toBe(true);
});

await assert('Browse page returns 200', async () => {
  const res = await get('/browse', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
});

// ── 2. Home API ───────────────────────────────────────────────────────────────
console.log('\n2. Home API');

let homeData = null;

await assert('GET /api/home returns continue_reading and recently_updated', async () => {
  const res = await get('/api/home', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
  homeData = await res.json();
  expect(homeData, 'data').toContainKey('continue_reading');
  expect(homeData, 'data').toContainKey('recently_updated');
  expect(homeData.continue_reading, 'continue_reading').toBeArray();
  expect(homeData.recently_updated, 'recently_updated').toBeArray();
});

await assert('GET /api/home stats has totalBooks and totalChapters', async () => {
  if (!homeData) {
    const res = await get('/api/home', { followRedirects: true });
    homeData = await res.json();
  }
  expect(homeData, 'data').toContainKey('stats');
  expect(typeof homeData.stats.totalBooks, 'totalBooks type').toBe('number');
  expect(typeof homeData.stats.totalChapters, 'totalChapters type').toBe('number');
});

// ── 3. Browse / ranking / search ──────────────────────────────────────────────
console.log('\n3. Browse / ranking / search');

await assert('GET /api/browse-page returns novels array', async () => {
  const res = await get('/api/browse-page?page=1', { followRedirects: true });
  expect(res.status, 'status').toBeOneOf(200, 503);
  if (res.status === 200) {
    const data = await res.json();
    // Accepts { novels: [...] } or { error: ... } (when MinIO cache is empty)
    expect(typeof data, 'response type').toBe('object');
  }
});

await assert('GET /api/ranking returns array or 502 (no data yet)', async () => {
  const res = await get('/api/ranking', { followRedirects: true });
  // 200 = ranking data exists; 502 = no ranking data scraped yet — both are expected
  expect(res.status, 'status').toBeOneOf(200, 502);
  if (res.status === 200) {
    const data = await res.json();
    expect(data, 'data').toBeArray();
  }
});

await assert('GET /api/search?q=shadow returns results object', async () => {
  const res = await get('/api/search?q=shadow', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  expect(typeof data, 'response type').toBe('object');
});

// ── 4. Books ──────────────────────────────────────────────────────────────────
console.log('\n4. Books');

// Find a real slug from /api/home
await assert('GET /api/home books are accessible', async () => {
  if (!homeData) {
    const res = await get('/api/home', { followRedirects: true });
    homeData = await res.json();
  }
  const books = homeData?.recently_updated ?? [];
  if (books.length > 0) {
    TEST_SLUG = books[0].slug;
    log(`  Using test slug: ${TEST_SLUG}`);
  }
  // Pass regardless — we just want to find a slug
});

if (TEST_SLUG) {
  await assert(`GET /api/book/${TEST_SLUG} returns book metadata`, async () => {
    const res = await get(`/api/book/${TEST_SLUG}`, { followRedirects: true });
    expect(res.status, 'status').toBe(200);
    const data = await res.json();
    // Returns { book: { slug, ... }, chapters: [...] }
    expect(data, 'data').toContainKey('book');
    expect(data.book, 'book').toContainKey('slug');
    expect(data.book.slug, 'slug').toBe(TEST_SLUG);
  });

  await assert(`Book detail page /${TEST_SLUG} returns 200`, async () => {
    const res = await get(`/${TEST_SLUG}`, { followRedirects: true });
    expect(res.status, 'status').toBe(200);
  });
} else {
  console.log('  ⚠  No books in database — skipping book-specific tests');
}

// ── 5. Voices ─────────────────────────────────────────────────────────────────
console.log('\n5. Voices');

await assert('GET /api/voices returns voices array', async () => {
  const res = await get('/api/voices', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  // Returns { voices: [...] }
  expect(data, 'data').toContainKey('voices');
  expect(data.voices, 'voices').toBeArray();
  expect(data.voices.length, 'voice count').toBeAbove(0);
});

// ── 6. Auth flow ──────────────────────────────────────────────────────────────
console.log('\n6. Auth flow');

const TEST_USER = `e2e_test_${Date.now()}`;
const TEST_PASS = 'E2eTestPassword1!';
let authCookie = '';

await assert('POST /api/auth/register creates new user', async () => {
  const res = await post('/api/auth/register', { username: TEST_USER, password: TEST_PASS });
  expect(res.status, 'status').toBeOneOf(200, 201);
  const data = await res.json();
  // Returns { token: "...", user: { id, username, role } }
  expect(data, 'response').toContainKey('user');
  expect(data.user, 'user').toContainKey('username');
  expect(data.user.username, 'username').toBe(TEST_USER);
  // Build cookie from token
  if (data.token) {
    authCookie = `libnovel_auth=${data.token}`;
  } else {
    authCookie = extractCookies(res);
  }
  log(`  Auth cookie: ${authCookie.slice(0, 40)}...`);
});

await assert('GET /api/auth/me returns current user when logged in', async () => {
  const res = await get('/api/auth/me', { followRedirects: true, headers: { Cookie: authCookie } });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  expect(data, 'data').toContainKey('username');
  expect(data.username, 'username').toBe(TEST_USER);
});

await assert('POST /api/auth/logout clears session', async () => {
  const res = await post('/api/auth/logout', {}, { cookie: authCookie });
  expect(res.status, 'status').toBeOneOf(200, 204);
});

await assert('POST /api/auth/login works after register', async () => {
  const res = await post('/api/auth/login', { username: TEST_USER, password: TEST_PASS });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  // Returns { token: "...", user: { id, username, role } }
  expect(data, 'response').toContainKey('user');
  expect(data.user.username, 'username').toBe(TEST_USER);
  if (data.token) {
    authCookie = `libnovel_auth=${data.token}`;
  } else {
    authCookie = extractCookies(res);
  }
});

await assert('GET /api/auth/me unauthenticated returns 401', async () => {
  const res = await get('/api/auth/me', { followRedirects: true });
  expect(res.status, 'status').toBe(401);
});

// ── 7. Progress ───────────────────────────────────────────────────────────────
console.log('\n7. Progress');

// /api/progress (root) is POST-only. Per-slug is GET/POST/DELETE via /api/progress/[slug].

if (TEST_SLUG) {
  await assert(`POST /api/progress/${TEST_SLUG} sets progress`, async () => {
    const res = await post(`/api/progress/${TEST_SLUG}`, { chapter: 1 });
    expect(res.status, 'status').toBeOneOf(200, 201);
    const data = await res.json();
    expect(data, 'data').toContainKey('ok');
  });

  await assert(`DELETE /api/progress/${TEST_SLUG} removes progress`, async () => {
    const res = await del(`/api/progress/${TEST_SLUG}`);
    expect(res.status, 'status').toBeOneOf(200, 204);
  });
} else {
  console.log('  ⚠  No books — skipping progress tests');
}

// ── 8. Library ────────────────────────────────────────────────────────────────
console.log('\n8. Library');

await assert('GET /api/library returns object', async () => {
  const res = await get('/api/library', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  expect(typeof data, 'data type').toBe('object');
});

if (TEST_SLUG) {
  await assert(`POST /api/library/${TEST_SLUG} saves book`, async () => {
    const res = await post(`/api/library/${TEST_SLUG}`, {});
    expect(res.status, 'status').toBeOneOf(200, 201);
  });

  await assert(`DELETE /api/library/${TEST_SLUG} removes book`, async () => {
    const res = await del(`/api/library/${TEST_SLUG}`);
    expect(res.status, 'status').toBeOneOf(200, 204);
  });
}

// ── 9. Settings ───────────────────────────────────────────────────────────────
console.log('\n9. Settings');

await assert('GET /api/settings returns settings object', async () => {
  const res = await get('/api/settings', { followRedirects: true });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  expect(typeof data, 'data type').toBe('object');
});

// ── 10. Sessions ──────────────────────────────────────────────────────────────
console.log('\n10. Sessions');

await assert('GET /api/sessions (authenticated) returns sessions array', async () => {
  const res = await get('/api/sessions', { followRedirects: true, headers: { Cookie: authCookie } });
  expect(res.status, 'status').toBe(200);
  const data = await res.json();
  // Returns { sessions: [...] }
  expect(data, 'data').toContainKey('sessions');
  expect(data.sessions, 'sessions').toBeArray();
});

// ── 11. Comments ──────────────────────────────────────────────────────────────
console.log('\n11. Comments');

if (TEST_SLUG) {
  await assert(`GET /api/comments/${TEST_SLUG} returns comments`, async () => {
    const res = await get(`/api/comments/${TEST_SLUG}`, { followRedirects: true });
    expect(res.status, 'status').toBe(200);
    const data = await res.json();
    // Returns { comments: [...], myVotes: {}, avatarUrls: {} }
    expect(data, 'data').toContainKey('comments');
    expect(data.comments, 'comments').toBeArray();
  });
}

// ── 12. Chapter endpoints ─────────────────────────────────────────────────────
console.log('\n12. Chapter endpoints');

if (TEST_SLUG) {
  await assert(`GET /api/chapter/${TEST_SLUG}/1 returns 200 or 404`, async () => {
    const res = await get(`/api/chapter/${TEST_SLUG}/1`, { followRedirects: true });
    // 200 if chapter exists in MinIO, 404 if not
    expect(res.status, 'status').toBeOneOf(200, 404);
  });

  await assert(`GET /api/chapter-text-preview/${TEST_SLUG}/1 returns 200 or error`, async () => {
    const res = await get(`/api/chapter-text-preview/${TEST_SLUG}/1`, { followRedirects: true });
    expect(res.status, 'status').toBeOneOf(200, 404, 500, 503);
  });
}

// ── Summary ───────────────────────────────────────────────────────────────────
console.log('\n─────────────────────────────────────');
console.log(`Results: ${passed} passed, ${failed} failed`);

if (failures.length > 0) {
  console.log('\nFailures:');
  for (const f of failures) {
    console.log(`  ✗  ${f.name}`);
    console.log(`     ${f.reason}`);
  }
}

console.log('─────────────────────────────────────\n');
process.exit(failed > 0 ? 1 : 0);
