import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { openDatabase } from '../src/db/connection.ts';
import { runMigrations } from '../src/db/migrate.ts';
import { createApp } from '../src/app.ts';
import { getFeedByUrl } from '../src/db/repos/feeds.ts';
import { fetchAndStore } from '../src/services/ingest.ts';
import type { Application } from 'express';
import type { AddressInfo } from 'node:net';
import type { Server } from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const migrationsDir = path.join(__dirname, '..', 'src', 'db', 'migrations');

interface StartedServer {
  server: Server;
  port: number;
}

function startServer(app: Application): Promise<StartedServer> {
  const server = http.createServer(app);
  return new Promise((resolve) =>
    server.listen(0, '127.0.0.1', () =>
      resolve({ server, port: (server.address() as AddressInfo).port }),
    ),
  );
}

async function readEvents(res: Response, timeoutMs = 2000): Promise<string> {
  if (!res.body) throw new Error('No response body');
  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buf = '';
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const result = await Promise.race<{ value: Uint8Array | null; done: boolean }>([
      reader.read(),
      new Promise<{ value: null; done: false; timeout: true }>((resolve) =>
        setTimeout(() => resolve({ value: null, done: false, timeout: true }), 50),
      ),
    ]);
    const { value, done } = result;
    if (value) buf += decoder.decode(value);
    if (buf.includes('event: items')) break;
    if (done) break;
  }
  try {
    reader.releaseLock();
  } catch {
    /* ignore */
  }
  return buf;
}

interface ApiResponse {
  status: number;
  json: any;
  setCookie: string;
}

async function req(
  base: string,
  cookie: string,
  method: string,
  path: string,
  body?: unknown,
  csrf?: string,
): Promise<ApiResponse> {
  const headers: Record<string, string> = {};
  if (cookie) headers.Cookie = cookie;
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  if (csrf) headers['X-CSRF-Token'] = csrf;
  const res = await fetch(base + path, {
    method,
    headers,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const setCookie = res.headers.get('set-cookie') ?? '';
  const text = await res.text();
  let json: any = null;
  try {
    json = JSON.parse(text);
  } catch {
    /* ignore */
  }
  return { status: res.status, json, setCookie };
}

test('sse delivers only subscribed feed events', async () => {
  const counts = { a: 0, b: 0 };
  const feedServer = http.createServer((req, res) => {
    res.setHeader('Content-Type', 'application/rss+xml');
    const url = req.url ?? '';
    if (url.startsWith('/a')) {
      counts.a += 1;
      res.end(
        `<?xml version="1.0"?><rss version="2.0"><channel><title>A</title><link>https://a.com</link><item><title>Item A ${counts.a}</title><link>https://a.com/${counts.a}</link><guid>a${counts.a}</guid><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate></item></channel></rss>`,
      );
    } else {
      counts.b += 1;
      res.end(
        `<?xml version="1.0"?><rss version="2.0"><channel><title>B</title><link>https://b.com</link><item><title>Item B ${counts.b}</title><link>https://b.com/${counts.b}</link><guid>b${counts.b}</guid><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate></item></channel></rss>`,
      );
    }
  });
  await new Promise<void>((resolve) => feedServer.listen(0, '127.0.0.1', resolve));
  const fp = (feedServer.address() as AddressInfo).port;

  const db = openDatabase(':memory:');
  runMigrations(db, migrationsDir);
  const app = createApp({
    config: { SESSION_SECRET: 'test-secret-at-least-16', NODE_ENV: 'test' },
    db,
    guard: async () => {
      /* noop */
    },
  });
  const { server, port } = await startServer(app);
  const base = `http://127.0.0.1:${port}`;

  try {
    // register users
    const r1 = await req(base, '', 'POST', '/api/auth/register', {
      email: 'a@example.com',
      password: 'password123',
    });
    const cookie1 = r1.setCookie.split(';')[0];
    const csrf1 = (await req(base, cookie1, 'GET', '/api/csrf')).json.token;
    const r2 = await req(base, '', 'POST', '/api/auth/register', {
      email: 'b@example.com',
      password: 'password123',
    });
    const cookie2 = r2.setCookie.split(';')[0];
    const csrf2 = (await req(base, cookie2, 'GET', '/api/csrf')).json.token;

    await req(
      base,
      cookie1,
      'POST',
      '/api/subscriptions',
      { url: `http://127.0.0.1:${fp}/a.xml` },
      csrf1,
    );
    await req(
      base,
      cookie2,
      'POST',
      '/api/subscriptions',
      { url: `http://127.0.0.1:${fp}/b.xml` },
      csrf2,
    );

    // open streams
    const res1 = await fetch(base + '/api/stream', { headers: { Cookie: cookie1 } });
    const res2 = await fetch(base + '/api/stream', { headers: { Cookie: cookie2 } });

    // trigger ingest for both feeds
    const feedA = getFeedByUrl(db, `http://127.0.0.1:${fp}/a.xml`)!;
    const feedB = getFeedByUrl(db, `http://127.0.0.1:${fp}/b.xml`)!;
    // need actual urls after discover; fetch stored url
    await fetchAndStore(db, getFeedByUrl(db, feedA.url)!, async () => {
      /* noop */
    });
    await fetchAndStore(db, getFeedByUrl(db, feedB.url)!, async () => {
      /* noop */
    });

    const buf1 = await readEvents(res1, 2000);
    const buf2 = await readEvents(res2, 2000);

    assert.match(buf1, /Item A 3/);
    assert.doesNotMatch(buf1, /Item B/);
    assert.match(buf2, /Item B 3/);
    assert.doesNotMatch(buf2, /Item A/);

    res1.body?.cancel?.();
    res2.body?.cancel?.();
  } finally {
    server.close();
    feedServer.close();
    db.close();
  }
});
