import {
    describe,
    it,
    expect,
    vi,
    beforeEach,
    afterEach,
    type MockInstance
} from 'vitest';
import {
    handle,
    setForwardedFor,
    shouldBypassProxyTimeout,
    stripHopByHopHeaders
} from './hooks.server';

// `BACKEND_URL` is read at module load time, so the values used in the
// asserts below assume the test env didn't set it. `?? 'http://localhost:8080'`
// is the default.
const DEFAULT_BACKEND = 'http://localhost:8080';

function makeEvent(path: string, init?: RequestInit) {
    const url = new URL(`http://app.example.com${path}`);
    const request = new Request(url, init);
    return { url, request } as Parameters<typeof handle>[0]['event'];
}

/** Like {@link makeEvent} but lets a test inject a specific `signal`
 *  on the underlying Request — the path SvelteKit uses to surface the
 *  client-disconnect signal. Used by the abort-chain tests. */
function makeEventWithSignal(path: string, signal: AbortSignal) {
    const url = new URL(`http://app.example.com${path}`);
    const request = new Request(url, { signal });
    return { url, request } as Parameters<typeof handle>[0]['event'];
}

describe('hooks.server proxy', () => {
    let fetchSpy: MockInstance<typeof globalThis.fetch>;

    beforeEach(() => {
        fetchSpy = vi.spyOn(globalThis, 'fetch');
    });
    afterEach(() => {
        vi.restoreAllMocks();
    });

    it('forwards /api/* requests to the backend, preserving status', async () => {
        fetchSpy.mockResolvedValueOnce(
            new Response(JSON.stringify({ status: 'ok' }), {
                status: 200,
                headers: { 'content-type': 'application/json' }
            })
        );
        const resolve = vi.fn();

        const event = makeEvent('/api/v1/health');
        const resp = await handle({ event, resolve });

        expect(resolve).not.toHaveBeenCalled();
        expect(fetchSpy).toHaveBeenCalledTimes(1);
        expect(fetchSpy.mock.calls[0][0]).toBe(`${DEFAULT_BACKEND}/api/v1/health`);
        expect(resp.status).toBe(200);
        expect(await resp.json()).toEqual({ status: 'ok' });
    });

    it('passes through the query string', async () => {
        fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
        const resolve = vi.fn();
        await handle({
            event: makeEvent('/api/v1/mangas?search=narto&limit=10'),
            resolve
        });
        expect(fetchSpy.mock.calls[0][0]).toBe(
            `${DEFAULT_BACKEND}/api/v1/mangas?search=narto&limit=10`
        );
    });

    it('strips the host header so the backend sees its own origin', async () => {
        fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
        const resolve = vi.fn();
        await handle({
            event: makeEvent('/api/v1/health', {
                headers: { host: 'app.example.com', cookie: 'mangalord_session=abc' }
            }),
            resolve
        });
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        const headers = init.headers as Headers;
        expect(headers.get('host')).toBeNull();
        // Cookies must still be forwarded — that's how the session reaches axum.
        expect(headers.get('cookie')).toBe('mangalord_session=abc');
    });

    it('forwards request bodies on POST', async () => {
        fetchSpy.mockResolvedValueOnce(new Response('{}', { status: 201 }));
        const resolve = vi.fn();
        await handle({
            event: makeEvent('/api/v1/auth/login', {
                method: 'POST',
                headers: { 'content-type': 'application/json' },
                body: JSON.stringify({ username: 'alice', password: 'hunter2hunter2' })
            }),
            resolve
        });
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.method).toBe('POST');
        expect(init.body).toBeDefined();
    });

    it('delegates non-/api requests to SvelteKit', async () => {
        const resolve = vi.fn().mockResolvedValue(new Response('page', { status: 200 }));

        await handle({ event: makeEvent('/manga/abc'), resolve });

        expect(fetchSpy).not.toHaveBeenCalled();
        expect(resolve).toHaveBeenCalledTimes(1);
    });

    it('returns 502 with the standard error envelope when the upstream is unreachable', async () => {
        // Silence the console.error the handler emits on failure so
        // the test output stays clean.
        const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
        fetchSpy.mockRejectedValueOnce(new TypeError('fetch failed'));

        const resolve = vi.fn();
        const resp = await handle({ event: makeEvent('/api/v1/health'), resolve });

        expect(resolve).not.toHaveBeenCalled();
        expect(resp.status).toBe(502);
        expect(resp.headers.get('content-type')).toContain('application/json');
        const body = await resp.json();
        expect(body.error.code).toBe('upstream_unavailable');
        expect(errSpy).toHaveBeenCalled();
    });

    it('strips every hop-by-hop header listed in RFC 7230 §6.1', async () => {
        // Defence in depth: axum doesn't emit these, but a future
        // middleware that did would otherwise leak per-connection
        // state across the proxy boundary.
        fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
        const resolve = vi.fn();
        await handle({
            event: makeEvent('/api/v1/health', {
                headers: {
                    host: 'app.example.com',
                    'content-length': '0',
                    connection: 'keep-alive',
                    'keep-alive': 'timeout=5',
                    'proxy-authenticate': 'Basic realm=x',
                    'proxy-authorization': 'Basic xyz',
                    te: 'trailers',
                    trailer: 'Expires',
                    'transfer-encoding': 'chunked',
                    upgrade: 'websocket',
                    // A non-hop-by-hop header to ensure non-targets
                    // aren't accidentally stripped.
                    'x-custom': 'pass-through'
                }
            }),
            resolve
        });
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        const headers = init.headers as Headers;
        for (const h of [
            'host',
            'content-length',
            'connection',
            'keep-alive',
            'proxy-authenticate',
            'proxy-authorization',
            'te',
            'trailer',
            'transfer-encoding',
            'upgrade'
        ]) {
            expect(headers.get(h), `${h} should be stripped`).toBeNull();
        }
        expect(headers.get('x-custom')).toBe('pass-through');
    });

    it('strips hop-by-hop headers from the proxied RESPONSE', async () => {
        // The upstream's connection-scoped headers must not ride along on
        // the re-streamed response (RFC 7230 §6.1). A normal header passes.
        fetchSpy.mockResolvedValueOnce(
            new Response('[]', {
                status: 200,
                headers: {
                    connection: 'keep-alive',
                    'keep-alive': 'timeout=5',
                    'transfer-encoding': 'chunked',
                    upgrade: 'h2c',
                    'content-type': 'application/json'
                }
            })
        );
        const resolve = vi.fn();
        const resp = await handle({ event: makeEvent('/api/v1/health'), resolve });
        for (const h of ['connection', 'keep-alive', 'transfer-encoding', 'upgrade']) {
            expect(resp.headers.get(h), `${h} should be stripped from response`).toBeNull();
        }
        // A normal response header survives.
        expect(resp.headers.get('content-type')).toContain('application/json');
    });

    it('aborts and returns 502 when the upstream stalls past the timeout', async () => {
        const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
        // Simulate an aborted fetch (AbortController.abort() raises a
        // DOMException with name 'AbortError' on Node's fetch). The
        // handler should treat it as the same upstream_unavailable
        // 502 it uses for any other network failure.
        const abortErr = new DOMException('aborted', 'AbortError');
        fetchSpy.mockRejectedValueOnce(abortErr);

        const resolve = vi.fn();
        const resp = await handle({ event: makeEvent('/api/v1/slow'), resolve });
        expect(resp.status).toBe(502);
        const body = await resp.json();
        expect(body.error.code).toBe('upstream_unavailable');
        expect(errSpy).toHaveBeenCalled();
    });

    it('attaches an AbortSignal to the upstream fetch so it can time out', async () => {
        fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
        const resolve = vi.fn();
        await handle({ event: makeEvent('/api/v1/health'), resolve });
        const init = fetchSpy.mock.calls[0][1] as RequestInit;
        expect(init.signal).toBeInstanceOf(AbortSignal);
        // The signal hasn't fired (handler returned in time), but its
        // presence is the contract this test is pinning.
        expect(init.signal?.aborted).toBe(false);
    });

    // T2 — client-disconnect abort chain (0.87.9). Closing an admin tab
    // must propagate into the upstream fetch so the backend's SSE
    // broadcast::Receiver + spawned task tear down immediately. Before
    // 0.87.9 the upstream fetch kept running until the proxy wall-clock
    // timeout fired.
    it('chains an already-aborted client signal into the upstream signal immediately', async () => {
        // Capture the signal the proxy hands fetch.
        let upstream: AbortSignal | undefined;
        fetchSpy.mockImplementation(async (_url, init) => {
            upstream = (init as RequestInit).signal ?? undefined;
            return new Response('[]', { status: 200 });
        });
        const resolve = vi.fn();
        // Build an event with an ALREADY-aborted request signal — the
        // shape SvelteKit hands us after a client disconnect that has
        // already settled.
        const ctrl = new AbortController();
        ctrl.abort();
        const event = makeEventWithSignal('/api/v1/health', ctrl.signal);
        await handle({ event, resolve });
        expect(upstream).toBeInstanceOf(AbortSignal);
        expect(upstream?.aborted).toBe(true);
    });

    it('returns 502 when fetch itself rejects with AbortError on an already-aborted signal', async () => {
        // Production Node fetch rejects synchronously with AbortError if
        // the signal is already aborted on entry — the prior test mocks
        // fetch to resolve 200, which leaves the rejection-path
        // observation uncovered. Mirror Node's real behaviour and
        // confirm the handler maps the rejection to 502 / upstream_unavailable
        // (same envelope as any other network failure).
        const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
        fetchSpy.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'));
        const resolve = vi.fn();
        const ctrl = new AbortController();
        ctrl.abort();
        const event = makeEventWithSignal('/api/v1/health', ctrl.signal);
        const resp = await handle({ event, resolve });
        expect(resp.status).toBe(502);
        const body = await resp.json();
        expect(body.error.code).toBe('upstream_unavailable');
        expect(errSpy).toHaveBeenCalled();
    });

    it('chains a mid-flight client-disconnect into the upstream signal', async () => {
        // The mid-flight shape: request.signal is live when the handler
        // starts, then aborts while the upstream fetch is in progress.
        const clientCtrl = new AbortController();
        let upstream: AbortSignal | undefined;
        fetchSpy.mockImplementation(async (_url, init) => {
            upstream = (init as RequestInit).signal ?? undefined;
            // Let the handler's chaining listener attach (it runs on
            // the next microtask after `handle()` calls fetch), THEN
            // trip the client signal. `AbortSignal#dispatchEvent` is
            // synchronous, so the listener runs in-line — no further
            // tick needed; we observe `upstream.aborted` immediately.
            await Promise.resolve();
            clientCtrl.abort();
            return new Response('[]', { status: 200 });
        });
        const resolve = vi.fn();
        const event = makeEventWithSignal('/api/v1/health', clientCtrl.signal);
        await handle({ event, resolve });
        expect(upstream?.aborted).toBe(true);
    });
});

// ----------------------------------------------------------------------------
// T1 — SSE bypass for the wall-clock proxy timeout.
// ----------------------------------------------------------------------------

describe('shouldBypassProxyTimeout', () => {
    it('returns true for an SSE Accept header', () => {
        const h = new Headers({ accept: 'text/event-stream' });
        expect(shouldBypassProxyTimeout(h)).toBe(true);
    });

    it('matches case-insensitively', () => {
        const h = new Headers({ accept: 'TEXT/EVENT-STREAM' });
        expect(shouldBypassProxyTimeout(h)).toBe(true);
    });

    it('matches when SSE is one of several Accept values', () => {
        const h = new Headers({ accept: 'text/event-stream, application/json' });
        expect(shouldBypassProxyTimeout(h)).toBe(true);
    });

    it('returns false for plain JSON / HTML requests', () => {
        expect(
            shouldBypassProxyTimeout(new Headers({ accept: 'application/json' }))
        ).toBe(false);
        expect(shouldBypassProxyTimeout(new Headers({ accept: 'text/html' }))).toBe(
            false
        );
    });

    it('returns false when Accept is absent', () => {
        expect(shouldBypassProxyTimeout(new Headers())).toBe(false);
    });
});

describe('stripHopByHopHeaders', () => {
    it('removes hop-by-hop headers and keeps the rest', () => {
        const src = new Headers({
            connection: 'keep-alive',
            'transfer-encoding': 'chunked',
            'content-type': 'application/json',
            'x-request-id': 'abc'
        });
        const out = stripHopByHopHeaders(src);
        expect(out.get('connection')).toBeNull();
        expect(out.get('transfer-encoding')).toBeNull();
        expect(out.get('content-type')).toBe('application/json');
        expect(out.get('x-request-id')).toBe('abc');
    });

    it('does not mutate the source headers', () => {
        const src = new Headers({ connection: 'close' });
        stripHopByHopHeaders(src);
        expect(src.get('connection')).toBe('close');
    });
});

describe('setForwardedFor', () => {
    it('overrides any client-supplied x-forwarded-for with the real address', () => {
        const headers = new Headers({ 'x-forwarded-for': '1.2.3.4', 'x-real-ip': '1.2.3.4' });
        setForwardedFor(headers, '203.0.113.9');
        expect(headers.get('x-forwarded-for')).toBe('203.0.113.9');
        // x-real-ip is dropped so it can't be used to spoof either.
        expect(headers.get('x-real-ip')).toBeNull();
    });

    it('strips the header entirely when no client address is available', () => {
        const headers = new Headers({ 'x-forwarded-for': '9.9.9.9' });
        setForwardedFor(headers, undefined);
        expect(headers.get('x-forwarded-for')).toBeNull();
    });

    it('trims whitespace and treats blank as absent', () => {
        const a = new Headers();
        setForwardedFor(a, '  198.51.100.2  ');
        expect(a.get('x-forwarded-for')).toBe('198.51.100.2');

        const b = new Headers({ 'x-forwarded-for': 'spoofed' });
        setForwardedFor(b, '   ');
        expect(b.get('x-forwarded-for')).toBeNull();
    });
});
