import { describe, it, expect, vi, afterEach } from 'vitest';
import { getCsrfToken, withCsrf, makeCsrfFetch, csrfFetch } from './cookies';

// Helper that builds a minimal document.cookie stub.
function stubDocument(cookieStr: string) {
	vi.stubGlobal('document', { cookie: cookieStr });
}

afterEach(() => {
	vi.unstubAllGlobals();
});

// ---------------------------------------------------------------------------
// getCsrfToken
// ---------------------------------------------------------------------------

describe('getCsrfToken', () => {
	it('returns null when document is undefined (server-side / Node)', () => {
		// No stub — document is undefined in Node vitest environment.
		expect(getCsrfToken()).toBeNull();
	});

	it('returns null when XSRF-TOKEN cookie is absent', () => {
		stubDocument('other=abc; another=xyz');
		expect(getCsrfToken()).toBeNull();
	});

	it('returns the token when XSRF-TOKEN is the only cookie', () => {
		stubDocument('XSRF-TOKEN=secret42');
		expect(getCsrfToken()).toBe('secret42');
	});

	it('returns the token when XSRF-TOKEN appears among other cookies', () => {
		stubDocument('fa_session=sess1; XSRF-TOKEN=csrf99; locale=de');
		expect(getCsrfToken()).toBe('csrf99');
	});

	it('URL-decodes the token value', () => {
		stubDocument('XSRF-TOKEN=hello%2Fworld');
		expect(getCsrfToken()).toBe('hello/world');
	});
});

// ---------------------------------------------------------------------------
// withCsrf
// ---------------------------------------------------------------------------

describe('withCsrf', () => {
	it('returns an empty RequestInit when cookie is absent and no init given', () => {
		// No document stub → getCsrfToken() returns null.
		expect(withCsrf()).toEqual({});
	});

	it('passes init through unchanged when cookie is absent', () => {
		const init: RequestInit = { method: 'POST', body: 'data' };
		expect(withCsrf(init)).toEqual(init);
	});

	it('injects X-XSRF-TOKEN header when token is present', () => {
		stubDocument('XSRF-TOKEN=tok123');
		const result = withCsrf({ method: 'POST' });
		expect((result.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok123');
	});

	it('preserves existing headers when injecting the CSRF token', () => {
		stubDocument('XSRF-TOKEN=tok123');
		const result = withCsrf({ headers: { 'Content-Type': 'application/json' } });
		const headers = result.headers as Headers;
		expect(headers.get('Content-Type')).toBe('application/json');
		expect(headers.get('X-XSRF-TOKEN')).toBe('tok123');
	});

	it('preserves the rest of the RequestInit when injecting', () => {
		stubDocument('XSRF-TOKEN=tok');
		const result = withCsrf({ method: 'PUT', body: '{}' });
		expect(result.method).toBe('PUT');
		expect(result.body).toBe('{}');
	});

	it('does not mutate the original init object', () => {
		stubDocument('XSRF-TOKEN=tok');
		const init: RequestInit = { method: 'POST' };
		withCsrf(init);
		expect(init.headers).toBeUndefined();
	});
});

// ---------------------------------------------------------------------------
// makeCsrfFetch
// ---------------------------------------------------------------------------

describe('makeCsrfFetch', () => {
	const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
	const SAFE = ['GET', 'HEAD'] as const;

	it('wraps the injected fetch — not the global one', async () => {
		const inner = vi.fn().mockResolvedValue(new Response());
		const globalMock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', globalMock);

		const wrapped = makeCsrfFetch(inner);
		await wrapped('/api/test', { method: 'GET' });

		expect(inner).toHaveBeenCalledOnce();
		expect(globalMock).not.toHaveBeenCalled();
	});

	it.each(MUTATING)('calls withCsrf for %s requests', async (method) => {
		stubDocument('XSRF-TOKEN=tok');
		const inner = vi.fn().mockResolvedValue(new Response());
		const wrapped = makeCsrfFetch(inner);

		await wrapped('/api/resource', { method });

		const passedInit = inner.mock.calls[0][1] as RequestInit;
		expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok');
	});

	it.each(SAFE)('passes %s requests through without CSRF header', async (method) => {
		stubDocument('XSRF-TOKEN=tok');
		const inner = vi.fn().mockResolvedValue(new Response());
		const wrapped = makeCsrfFetch(inner);

		await wrapped('/api/resource', { method });

		// init is passed as-is (no headers added).
		const passedInit = inner.mock.calls[0][1] as RequestInit;
		expect(passedInit?.headers).toBeUndefined();
	});

	it('defaults to GET semantics when method is omitted', async () => {
		stubDocument('XSRF-TOKEN=tok');
		const inner = vi.fn().mockResolvedValue(new Response());
		const wrapped = makeCsrfFetch(inner);

		await wrapped('/api/resource');

		expect(inner).toHaveBeenCalledOnce();
		const passedInit = inner.mock.calls[0][1] as RequestInit | undefined;
		expect(passedInit?.headers).toBeUndefined();
	});

	it('is case-insensitive for method names', async () => {
		stubDocument('XSRF-TOKEN=tok');
		const inner = vi.fn().mockResolvedValue(new Response());
		const wrapped = makeCsrfFetch(inner);

		await wrapped('/api/resource', { method: 'post' });

		const passedInit = inner.mock.calls[0][1] as RequestInit;
		expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok');
	});
});

// ---------------------------------------------------------------------------
// csrfFetch — uses the global fetch (regression guard for the module-const bug)
// ---------------------------------------------------------------------------

describe('csrfFetch', () => {
	const MUTATING = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
	const SAFE = ['GET', 'HEAD'] as const;

	it('picks up a vi.stubGlobal fetch stub — does NOT bypass the mock', async () => {
		// This is the regression test for the original module-level-const bug.
		// If csrfFetch were `export const csrfFetch = makeCsrfFetch(fetch)` the
		// reference captured at module init time would skip any later stub.
		const mock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', mock);

		await csrfFetch('/api/test', { method: 'GET' });

		expect(mock).toHaveBeenCalledOnce();
	});

	it.each(MUTATING)('injects X-XSRF-TOKEN for %s when cookie is set', async (method) => {
		stubDocument('XSRF-TOKEN=csrf-val');
		const mock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', mock);

		await csrfFetch('/api/resource', { method });

		const passedInit = mock.mock.calls[0][1] as RequestInit;
		expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('csrf-val');
	});

	it.each(SAFE)('does NOT inject CSRF header for %s', async (method) => {
		stubDocument('XSRF-TOKEN=csrf-val');
		const mock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', mock);

		await csrfFetch('/api/resource', { method });

		const passedInit = mock.mock.calls[0][1] as RequestInit;
		expect(passedInit?.headers).toBeUndefined();
	});

	it('does not inject header when XSRF-TOKEN cookie is absent', async () => {
		// No document stub → getCsrfToken() returns null → withCsrf() is a no-op.
		const mock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', mock);

		await csrfFetch('/api/resource', { method: 'POST' });

		const passedInit = mock.mock.calls[0][1] as RequestInit;
		// withCsrf returns { ...init } when no token — headers key won't be set.
		const headers = passedInit.headers;
		if (headers instanceof Headers) {
			expect(headers.has('X-XSRF-TOKEN')).toBe(false);
		} else {
			expect((headers as Record<string, string> | undefined)?.['X-XSRF-TOKEN']).toBeUndefined();
		}
	});

	it('forwards the URL and init body to fetch', async () => {
		const mock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', mock);

		await csrfFetch('/api/resource', { method: 'GET', body: null });

		expect(mock.mock.calls[0][0]).toBe('/api/resource');
	});

	it('is case-insensitive for method names', async () => {
		stubDocument('XSRF-TOKEN=tok');
		const mock = vi.fn().mockResolvedValue(new Response());
		vi.stubGlobal('fetch', mock);

		await csrfFetch('/api/resource', { method: 'delete' });

		const passedInit = mock.mock.calls[0][1] as RequestInit;
		expect((passedInit.headers as Headers).get('X-XSRF-TOKEN')).toBe('tok');
	});
});
