/**
 * Unit tests for the GitHub repository crawler (TRUEREF-0003).
 *
 * All GitHub API calls are intercepted via vi.stubGlobal('fetch', ...) so
 * that no real network traffic is produced.
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

import { crawl } from './github.crawler.js';
import { shouldIndexFile, detectLanguage, INDEXABLE_EXTENSIONS, MAX_FILE_SIZE_BYTES } from './file-filter.js';
import { GitHubRateLimiter, Semaphore, withRetry } from './rate-limiter.js';
import {
	AuthenticationError,
	PermissionError,
	RepositoryNotFoundError
} from './types.js';

// ---------------------------------------------------------------------------
// Mock fetch helpers
// ---------------------------------------------------------------------------

type FetchHandler = (url: string, init?: RequestInit) => Response;

function stubFetch(handler: FetchHandler) {
	vi.stubGlobal(
		'fetch',
		vi.fn((url: string, init?: RequestInit) => Promise.resolve(handler(url, init)))
	);
}

function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
	return new Response(JSON.stringify(body), {
		status,
		headers: {
			'Content-Type': 'application/json',
			'X-RateLimit-Remaining': '4999',
			'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 3600),
			...headers
		}
	});
}

function textResponse(body: string, status = 200, headers: Record<string, string> = {}): Response {
	return new Response(body, {
		status,
		headers: {
			'Content-Type': 'text/plain',
			'X-RateLimit-Remaining': '4999',
			'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 3600),
			...headers
		}
	});
}

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

const REPO_INFO = {
	default_branch: 'main',
	stargazers_count: 42
};

const TREE_RESPONSE = {
	tree: [
		{ path: 'README.md', type: 'blob', size: 1024, sha: 'sha-readme', url: '' },
		{ path: 'src/index.ts', type: 'blob', size: 512, sha: 'sha-index', url: '' },
		{ path: 'src/utils.ts', type: 'blob', size: 256, sha: 'sha-utils', url: '' },
		{ path: 'package.json', type: 'blob', size: 128, sha: 'sha-pkg', url: '' },
		{ path: 'dist/bundle.js', type: 'blob', size: 9999, sha: 'sha-dist', url: '' }, // excluded by default
		{ path: 'node_modules/lodash/index.js', type: 'blob', size: 100, sha: 'sha-nm', url: '' }, // excluded
		{ path: 'image.png', type: 'blob', size: 4096, sha: 'sha-img', url: '' }, // non-indexable
		{ path: 'src', type: 'tree', size: 0, sha: 'sha-src-tree', url: '' }
	],
	truncated: false
};

const COMMIT_SHA = 'deadbeef1234567890abcdef';

// ---------------------------------------------------------------------------
// shouldIndexFile unit tests
// ---------------------------------------------------------------------------

describe('shouldIndexFile()', () => {
	it('returns true for a .ts file within size limit', () => {
		expect(shouldIndexFile('src/index.ts', 1000)).toBe(true);
	});

	it('returns false for a .png file (non-indexable extension)', () => {
		expect(shouldIndexFile('assets/logo.png', 100)).toBe(false);
	});

	it('returns false when file exceeds MAX_FILE_SIZE_BYTES', () => {
		expect(shouldIndexFile('big.ts', MAX_FILE_SIZE_BYTES + 1)).toBe(false);
	});

	it('returns false for a file in node_modules/', () => {
		expect(shouldIndexFile('node_modules/lodash/index.js', 100)).toBe(false);
	});

	it('returns false for a file in dist/', () => {
		expect(shouldIndexFile('dist/bundle.js', 100)).toBe(false);
	});

	it('respects config.excludeFiles (exact basename)', () => {
		expect(shouldIndexFile('src/secret.ts', 100, { excludeFiles: ['secret.ts'] })).toBe(false);
	});

	it('does not exclude a file whose basename merely contains the excluded name', () => {
		expect(shouldIndexFile('src/not-secret.ts', 100, { excludeFiles: ['secret.ts'] })).toBe(true);
	});

	it('respects config.excludeFolders prefix', () => {
		expect(shouldIndexFile('internal/config.ts', 100, { excludeFolders: ['internal/'] })).toBe(false);
	});

	it('allows files outside of config.excludeFolders', () => {
		expect(shouldIndexFile('public/api.ts', 100, { excludeFolders: ['internal/'] })).toBe(true);
	});

	it('restricts to config.folders allowlist when specified', () => {
		const config = { folders: ['docs/'] };
		expect(shouldIndexFile('src/index.ts', 100, config)).toBe(false);
		expect(shouldIndexFile('docs/guide.md', 100, config)).toBe(true);
	});

	it('returns true when config.folders is an empty array (no restriction)', () => {
		expect(shouldIndexFile('src/index.ts', 100, { folders: [] })).toBe(true);
	});

	it('handles all default-excluded directories', () => {
		const excluded = [
			'node_modules/pkg/index.js',
			'.git/config',
			'dist/out.js',
			'build/app.js',
			'coverage/lcov.info',
			'.next/server.js',
			'__pycache__/mod.py',
			'vendor/lib.go',
			'target/release.rs',
			'.cache/file.ts'
		];
		for (const path of excluded) {
			expect(shouldIndexFile(path, 100), `should exclude ${path}`).toBe(false);
		}
	});

	it('INDEXABLE_EXTENSIONS covers all expected types', () => {
		const required = ['.md', '.ts', '.py', '.go', '.rs', '.json', '.svelte'];
		for (const ext of required) {
			expect(INDEXABLE_EXTENSIONS.has(ext), `missing extension ${ext}`).toBe(true);
		}
	});
});

// ---------------------------------------------------------------------------
// detectLanguage unit tests
// ---------------------------------------------------------------------------

describe('detectLanguage()', () => {
	it('detects typescript', () => expect(detectLanguage('foo.ts')).toBe('typescript'));
	it('detects tsx as typescript', () => expect(detectLanguage('foo.tsx')).toBe('typescript'));
	it('detects javascript', () => expect(detectLanguage('foo.js')).toBe('javascript'));
	it('detects python', () => expect(detectLanguage('foo.py')).toBe('python'));
	it('detects go', () => expect(detectLanguage('foo.go')).toBe('go'));
	it('detects rust', () => expect(detectLanguage('foo.rs')).toBe('rust'));
	it('detects markdown', () => expect(detectLanguage('README.md')).toBe('markdown'));
	it('detects svelte', () => expect(detectLanguage('App.svelte')).toBe('svelte'));
	it('detects yaml', () => expect(detectLanguage('config.yaml')).toBe('yaml'));
	it('returns empty string for unknown extension', () => expect(detectLanguage('file.xyz')).toBe(''));
	it('is case-insensitive for extensions', () => expect(detectLanguage('FILE.TS')).toBe('typescript'));
});

// ---------------------------------------------------------------------------
// GitHubRateLimiter unit tests
// ---------------------------------------------------------------------------

describe('GitHubRateLimiter', () => {
	it('defaults to 5000 remaining requests', () => {
		const limiter = new GitHubRateLimiter();
		expect(limiter.remainingRequests).toBe(5000);
	});

	it('updates remaining and resetAt from headers', () => {
		const limiter = new GitHubRateLimiter();
		const resetEpoch = Math.floor(Date.now() / 1000) + 3600;
		const headers = new Headers({
			'X-RateLimit-Remaining': '42',
			'X-RateLimit-Reset': String(resetEpoch)
		});
		limiter.updateFromHeaders(headers);
		expect(limiter.remainingRequests).toBe(42);
		expect(limiter.resetTimestamp).toBe(resetEpoch * 1000);
	});

	it('does not mutate state when headers are absent', () => {
		const limiter = new GitHubRateLimiter();
		limiter.updateFromHeaders(new Headers());
		expect(limiter.remainingRequests).toBe(5000);
	});

	it('waitIfNeeded resolves immediately when remaining > 10', async () => {
		const limiter = new GitHubRateLimiter();
		const start = Date.now();
		await limiter.waitIfNeeded();
		expect(Date.now() - start).toBeLessThan(100);
	});
});

// ---------------------------------------------------------------------------
// Semaphore unit tests
// ---------------------------------------------------------------------------

describe('Semaphore', () => {
	it('allows up to concurrency tasks to run simultaneously', async () => {
		const sem = new Semaphore(2);
		let active = 0;
		let maxActive = 0;

		const task = () =>
			sem.run(async () => {
				active++;
				maxActive = Math.max(maxActive, active);
				await new Promise((r) => setTimeout(r, 10));
				active--;
			});

		await Promise.all([task(), task(), task(), task()]);
		expect(maxActive).toBeLessThanOrEqual(2);
	});

	it('resolves all tasks even when queued', async () => {
		const sem = new Semaphore(1);
		const results: number[] = [];
		await Promise.all(
			[1, 2, 3].map((n) =>
				sem.run(async () => {
					results.push(n);
				})
			)
		);
		expect(results).toHaveLength(3);
	});
});

// ---------------------------------------------------------------------------
// withRetry unit tests
// ---------------------------------------------------------------------------

describe('withRetry()', () => {
	it('returns the result on first success', async () => {
		const result = await withRetry(() => Promise.resolve(42));
		expect(result).toBe(42);
	});

	it('retries on failure and returns eventual success', async () => {
		let calls = 0;
		const result = await withRetry(async () => {
			calls++;
			if (calls < 3) throw new Error('transient');
			return 'ok';
		}, 3);
		expect(result).toBe('ok');
		expect(calls).toBe(3);
	});

	it('throws after exhausting all attempts', async () => {
		await expect(
			withRetry(() => Promise.reject(new Error('always fails')), 3)
		).rejects.toThrow('always fails');
	});
});

// ---------------------------------------------------------------------------
// crawl() integration tests (fetch mocked)
// ---------------------------------------------------------------------------

describe('crawl()', () => {
	beforeEach(() => {
		vi.useFakeTimers();
	});

	afterEach(() => {
		vi.restoreAllMocks();
		vi.useRealTimers();
	});

	function setupDefaultMocks(overrides: Partial<Record<string, Response>> = {}) {
		stubFetch((url) => {
			// Repo info
			if (url === 'https://api.github.com/repos/owner/repo') {
				return overrides[url] ?? jsonResponse(REPO_INFO);
			}
			// Commit SHA
			if (url === 'https://api.github.com/repos/owner/repo/commits/main') {
				return overrides[url] ?? textResponse(COMMIT_SHA);
			}
			// File tree
			if (url.startsWith('https://api.github.com/repos/owner/repo/git/trees/main')) {
				return overrides[url] ?? jsonResponse(TREE_RESPONSE);
			}
			// Raw content (raw.githubusercontent.com)
			if (url.startsWith('https://raw.githubusercontent.com/')) {
				const filePath = url.split('/').slice(6).join('/');
				return overrides[url] ?? textResponse(`// content of ${filePath}`);
			}
			return new Response('not found', { status: 404 });
		});
	}

	it('returns files that pass the filter', async () => {
		setupDefaultMocks();

		const result = await crawl({ owner: 'owner', repo: 'repo' });

		// dist/ and node_modules/ should be excluded; .png should be excluded.
		// Expected: README.md, src/index.ts, src/utils.ts, package.json
		expect(result.files.length).toBeGreaterThanOrEqual(4);
		expect(result.files.every((f) => !f.path.startsWith('dist/'))).toBe(true);
		expect(result.files.every((f) => !f.path.startsWith('node_modules/'))).toBe(true);
		expect(result.files.every((f) => !f.path.endsWith('.png'))).toBe(true);
	});

	it('sets branch to the default_branch when no ref is given', async () => {
		setupDefaultMocks();
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		expect(result.branch).toBe('main');
	});

	it('uses the provided ref when specified', async () => {
		stubFetch((url) => {
			if (url === 'https://api.github.com/repos/owner/repo') {
				return jsonResponse(REPO_INFO);
			}
			if (url.includes('/git/trees/v2.0.0')) {
				return jsonResponse({ tree: [], truncated: false });
			}
			if (url.includes('/commits/v2.0.0')) {
				return textResponse('tagsha');
			}
			return textResponse('content');
		});

		const result = await crawl({ owner: 'owner', repo: 'repo', ref: 'v2.0.0' });
		expect(result.branch).toBe('v2.0.0');
	});

	it('populates commitSha from the commits endpoint', async () => {
		setupDefaultMocks();
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		expect(result.commitSha).toBe(COMMIT_SHA);
	});

	it('sets correct sha on each CrawledFile from the tree', async () => {
		setupDefaultMocks();
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		const readme = result.files.find((f) => f.path === 'README.md');
		expect(readme).toBeDefined();
		expect(readme!.sha).toBe('sha-readme');
	});

	it('attaches language to each CrawledFile', async () => {
		setupDefaultMocks();
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		const indexTs = result.files.find((f) => f.path === 'src/index.ts');
		expect(indexTs?.language).toBe('typescript');
		const readme = result.files.find((f) => f.path === 'README.md');
		expect(readme?.language).toBe('markdown');
	});

	it('reports progress via onProgress callback', async () => {
		setupDefaultMocks();
		const calls: Array<[number, number]> = [];
		await crawl({
			owner: 'owner',
			repo: 'repo',
			onProgress: (p, t) => calls.push([p, t])
		});
		expect(calls.length).toBeGreaterThan(0);
		// Total must remain constant across all calls.
		const totals = calls.map(([, t]) => t);
		expect(totals.every((t) => t === totals[0])).toBe(true);
	});

	it('skips files that fail to download without throwing', async () => {
		stubFetch((url) => {
			if (url === 'https://api.github.com/repos/owner/repo') {
				return jsonResponse(REPO_INFO);
			}
			if (url.includes('/git/trees/main')) {
				return jsonResponse({
					tree: [{ path: 'src/index.ts', type: 'blob', size: 100, sha: 'sha1', url: '' }],
					truncated: false
				});
			}
			if (url.includes('/commits/main')) {
				return textResponse(COMMIT_SHA);
			}
			// All content downloads fail.
			return new Response('error', { status: 500 });
		});

		// Should not throw; just return zero files.
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		expect(result.files).toHaveLength(0);
		expect(result.totalFiles).toBe(1);
	});

	it('throws RepositoryNotFoundError on 404', async () => {
		stubFetch((url) => {
			if (url === 'https://api.github.com/repos/owner/missing') {
				return jsonResponse({ message: 'Not Found' }, 404, {
					'X-RateLimit-Remaining': '4999',
					'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 3600)
				});
			}
			return new Response('not found', { status: 404 });
		});

		await expect(crawl({ owner: 'owner', repo: 'missing' })).rejects.toThrow(
			RepositoryNotFoundError
		);
	});

	it('throws AuthenticationError on 401', async () => {
		stubFetch(() =>
			new Response('Unauthorized', {
				status: 401,
				headers: {
					'X-RateLimit-Remaining': '0',
					'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 3600)
				}
			})
		);

		await expect(crawl({ owner: 'owner', repo: 'repo', token: 'bad-token' })).rejects.toThrow(
			AuthenticationError
		);
	});

	it('throws PermissionError on 403 without rate-limit exhaustion', async () => {
		stubFetch(() =>
			new Response('Forbidden', {
				status: 403,
				headers: {
					'X-RateLimit-Remaining': '100',
					'X-RateLimit-Reset': String(Math.floor(Date.now() / 1000) + 3600)
				}
			})
		);

		await expect(crawl({ owner: 'owner', repo: 'repo' })).rejects.toThrow(PermissionError);
	});

	it('respects config.folders allowlist when provided', async () => {
		setupDefaultMocks();
		const result = await crawl({
			owner: 'owner',
			repo: 'repo',
			config: { folders: ['src/'] }
		});
		// Only src/ files should be present.
		expect(result.files.every((f) => f.path.startsWith('src/'))).toBe(true);
	});

	it('applies config.excludeFiles filter', async () => {
		setupDefaultMocks();
		const result = await crawl({
			owner: 'owner',
			repo: 'repo',
			config: { excludeFiles: ['package.json'] }
		});
		expect(result.files.some((f) => f.path === 'package.json')).toBe(false);
	});

	it('returns correct skippedFiles count', async () => {
		setupDefaultMocks();
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		// dist/, node_modules/, and .png are the excluded items = 3
		expect(result.skippedFiles).toBe(3);
	});

	it('uses auth token in requests to GitHub API', async () => {
		const capturedHeaders: Record<string, string>[] = [];

		stubFetch((url, init) => {
			const headers = Object.fromEntries(
				Object.entries((init?.headers as Record<string, string>) ?? {})
			);
			capturedHeaders.push(headers);

			if (url === 'https://api.github.com/repos/owner/repo') {
				return jsonResponse(REPO_INFO);
			}
			if (url.includes('/git/trees/main')) {
				return jsonResponse({ tree: [], truncated: false });
			}
			if (url.includes('/commits/main')) {
				return textResponse(COMMIT_SHA);
			}
			return textResponse('content');
		});

		await crawl({ owner: 'owner', repo: 'repo', token: 'ghp_mysecrettoken' });

		const apiCalls = capturedHeaders.filter((h) => h.Authorization);
		expect(apiCalls.length).toBeGreaterThan(0);
		expect(apiCalls[0].Authorization).toBe('Bearer ghp_mysecrettoken');
	});

	it('handles a tree with zero indexable files gracefully', async () => {
		stubFetch((url) => {
			if (url === 'https://api.github.com/repos/owner/repo') return jsonResponse(REPO_INFO);
			if (url.includes('/git/trees/main'))
				return jsonResponse({
					tree: [
						{ path: 'image.png', type: 'blob', size: 100, sha: 'sha1', url: '' },
						{ path: 'video.mp4', type: 'blob', size: 1000, sha: 'sha2', url: '' }
					],
					truncated: false
				});
			if (url.includes('/commits/main')) return textResponse(COMMIT_SHA);
			return textResponse('content');
		});

		const result = await crawl({ owner: 'owner', repo: 'repo' });
		expect(result.files).toHaveLength(0);
		expect(result.totalFiles).toBe(0);
		expect(result.skippedFiles).toBe(2);
	});

	it('reads and applies config from trueref.json found in the tree', async () => {
		const truerefConfig = { excludeFiles: ['package.json'] };

		stubFetch((url) => {
			if (url === 'https://api.github.com/repos/owner/repo') return jsonResponse(REPO_INFO);
			if (url.includes('/git/trees/main')) {
				return jsonResponse({
					tree: [
						{ path: 'trueref.json', type: 'blob', size: 50, sha: 'sha-cfg', url: '' },
						{ path: 'src/index.ts', type: 'blob', size: 200, sha: 'sha-idx', url: '' },
						{ path: 'package.json', type: 'blob', size: 100, sha: 'sha-pkg', url: '' }
					],
					truncated: false
				});
			}
			if (url.includes('/commits/main')) return textResponse(COMMIT_SHA);
			if (url.includes('trueref.json')) return textResponse(JSON.stringify(truerefConfig));
			if (url.includes('src/index.ts')) return textResponse('export const x = 1;');
			if (url.includes('package.json')) return textResponse('{"name":"test"}');
			return textResponse('content');
		});

		// No caller-supplied config — crawler should auto-detect trueref.json.
		const result = await crawl({ owner: 'owner', repo: 'repo' });
		expect(result.files.some((f) => f.path === 'package.json')).toBe(false);
		expect(result.files.some((f) => f.path === 'src/index.ts')).toBe(true);
	});
});
