/**
 * Embedded Git Smart HTTP Server
 *
 * Enables authenticated git clone, fetch, and push over HTTP.
 * Uses Basic Auth against AuthService for credentials.
 *
 * Endpoints:
 *   GET  /git/:repo/info/refs?service=git-upload-pack   — advertise refs (clone/fetch)
 *   GET  /git/:repo/info/refs?service=git-receive-pack  — advertise refs (push)
 *   POST /git/:repo/git-upload-pack                     — clone/fetch data
 *   POST /git/:repo/git-receive-pack                    — push data
 *
 * Access rules:
 *   - No users configured (open mode) → full access
 *   - Admin → all folders
 *   - Member → only granted folders
 *   - Private repos → owner only
 */
import { spawn } from 'child_process';
import { join } from 'path';
import { existsSync } from 'fs';
import * as access from '../../services/access-control.js';
import { checkRateLimit, recordFailedAttempt, clearAttempts } from './auth-api.js';
/**
 * Resolve Basic Auth header to an AuthUser, or null if open mode.
 * Returns { user } on success, or { error, statusCode } on failure.
 */
function authenticateBasic(authHeader, authService) {
    // Open mode — no users configured, allow everything
    if (!authService.hasUsers()) {
        return { user: null };
    }
    if (!authHeader || !authHeader.startsWith('Basic ')) {
        return { error: 'Authentication required', statusCode: 401 };
    }
    const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
    const colonIdx = decoded.indexOf(':');
    if (colonIdx === -1) {
        return { error: 'Invalid credentials', statusCode: 401 };
    }
    const email = decoded.slice(0, colonIdx);
    const password = decoded.slice(colonIdx + 1);
    // Rate limit check
    if (checkRateLimit(email) > 0) {
        return { error: 'Too many login attempts', statusCode: 429 };
    }
    // Accept a per-user API token as password (username is ignored). Tokens
    // are minted via POST /api/auth/tokens and carry a concrete user identity.
    if (password.startsWith('sg_')) {
        const tokenUser = authService.verifyApiToken(password);
        if (tokenUser) {
            clearAttempts(email);
            return { user: tokenUser };
        }
        return { error: 'Invalid or revoked API token', statusCode: 401 };
    }
    // Accept JWT token as password (issued by login → cookie, or by `studiograph
    // clone` bootstrap). JWTs always start with "eyJ" (base64url of `{"`) so we
    // can detect them without attempting a failed password auth first.
    if (password.startsWith('eyJ')) {
        const jwtUser = authService.verifyToken(password);
        if (jwtUser) {
            clearAttempts(email);
            return { user: jwtUser };
        }
        return { error: 'Invalid or expired token — run `studiograph admin token issue` to mint a fresh API token', statusCode: 401 };
    }
    const result = authService.authenticate(email, password);
    if (!result) {
        recordFailedAttempt(email);
        return { error: 'Invalid credentials', statusCode: 401 };
    }
    clearAttempts(email);
    return { user: result.user };
}
/**
 * Check whether a user can access the named folder via git.
 *
 * The workspace owner gets implicit access to *shared* folders (memberCount > 1)
 * they haven't been explicitly added to — shared state is owner-visible by design.
 * Solo folders (memberCount === 1, i.e. de-facto private) remain gated: only the
 * single member can access, even if the caller is the workspace owner.
 */
function canAccessFolder(user, repoName, authService, workspaceManager, includePrivate = false) {
    // Delegate to the shared helper so personal-folder restrictions apply
    // here too — a stale folder_access row must not let a non-owner clone
    // someone else's personal repo via git transport. `includePrivate` is
    // an owner-only admin escalation (see access-control.ts).
    return access.hasRepoAccess(user ?? undefined, repoName, workspaceManager, authService, { includePrivate });
}
/** Returns true only if the path is an initialized git repo. */
function isGitRepo(repoPath) {
    return existsSync(join(repoPath, '.git'));
}
/**
 * Resolve a repo name to its absolute path on disk.
 * Returns null if the repo doesn't exist, or is private and the user isn't the owner.
 */
function resolveRepoPath(repoName, workspaceManager, workspacePath, user, authService, includePrivate = false) {
    const config = workspaceManager.getRepoConfig(repoName);
    if (!config)
        return null;
    // Delegate to the shared access-control helper — same rule as canAccessFolder
    // and the rest of the API layer (personal-folder restriction enforced).
    if (!access.hasRepoAccess(user ?? undefined, repoName, workspaceManager, authService, { includePrivate })) {
        return null;
    }
    return join(workspacePath, config.path);
}
/** Parse the `X-Studiograph-Include-Private` header from a git HTTP
 *  request into a boolean. The flag is gated to owners by the access
 *  helpers; setting it as a non-owner is a no-op. */
function reqIncludePrivate(req) {
    const raw = req.headers['x-studiograph-include-private'];
    const v = Array.isArray(raw) ? raw[0] : raw;
    return v === '1' || v === 'true';
}
export async function registerGitHttpRoutes(fastify, workspaceManager, authService, workspacePath, wsHub) {
    // GET /git/:repo/info/refs?service=git-upload-pack|git-receive-pack
    fastify.get('/git/:repo/info/refs', async (req, reply) => {
        const { repo } = req.params;
        const service = req.query.service;
        if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
            return reply.status(403).send('Dumb HTTP protocol not supported');
        }
        // Authenticate
        const auth = authenticateBasic(req.headers.authorization, authService);
        if ('error' in auth) {
            reply.header('WWW-Authenticate', 'Basic realm="Studiograph Git"');
            return reply.status(auth.statusCode).send(auth.error);
        }
        // Resolve repo (owner-only `include_private` header for admin ops)
        const includePrivate = reqIncludePrivate(req);
        const repoPath = resolveRepoPath(repo, workspaceManager, workspacePath, auth.user, authService, includePrivate);
        if (!repoPath || !isGitRepo(repoPath)) {
            return reply.status(404).send('Repository not found');
        }
        // Check folder access
        if (!canAccessFolder(auth.user, repo, authService, workspaceManager, includePrivate)) {
            return reply.status(404).send('Repository not found');
        }
        // Push requires folder access (admin → all, member → granted only)
        if (service === 'git-receive-pack' && !canAccessFolder(auth.user, repo, authService, workspaceManager, includePrivate)) {
            return reply.status(403).send('Push access denied');
        }
        // Spawn git to advertise refs
        const gitService = service === 'git-upload-pack' ? 'upload-pack' : 'receive-pack';
        reply.raw.writeHead(200, {
            'Content-Type': `application/x-${service}-advertisement`,
            'Cache-Control': 'no-cache',
        });
        // Write the pkt-line header for smart HTTP
        const header = `# service=${service}\n`;
        const headerLen = (header.length + 4).toString(16).padStart(4, '0');
        reply.raw.write(`${headerLen}${header}`);
        reply.raw.write('0000');
        const proc = spawn('git', [gitService, '--stateless-rpc', '--advertise-refs', repoPath], {
            stdio: ['pipe', 'pipe', 'pipe'],
        });
        proc.stdout.pipe(reply.raw, { end: false });
        proc.stderr.on('data', (data) => {
            fastify.log.warn(`git ${gitService} stderr: ${data.toString()}`);
        });
        proc.on('close', () => {
            reply.raw.end();
        });
        proc.on('error', (err) => {
            fastify.log.error(err, `Failed to spawn git ${gitService}`);
            if (!reply.raw.headersSent) {
                reply.raw.writeHead(500);
            }
            reply.raw.end();
        });
        // Don't let Fastify auto-send a response
        return reply;
    });
    // POST /git/:repo/git-upload-pack — clone/fetch
    fastify.post('/git/:repo/git-upload-pack', async (req, reply) => {
        const { repo } = req.params;
        const auth = authenticateBasic(req.headers.authorization, authService);
        if ('error' in auth) {
            reply.header('WWW-Authenticate', 'Basic realm="Studiograph Git"');
            return reply.status(auth.statusCode).send(auth.error);
        }
        const includePrivate = reqIncludePrivate(req);
        const repoPath = resolveRepoPath(repo, workspaceManager, workspacePath, auth.user, authService, includePrivate);
        if (!repoPath || !isGitRepo(repoPath)) {
            return reply.status(404).send('Repository not found');
        }
        if (!canAccessFolder(auth.user, repo, authService, workspaceManager, includePrivate)) {
            return reply.status(404).send('Repository not found');
        }
        reply.raw.writeHead(200, {
            'Content-Type': 'application/x-git-upload-pack-result',
            'Cache-Control': 'no-cache',
        });
        const proc = spawn('git', ['upload-pack', '--stateless-rpc', repoPath], {
            stdio: ['pipe', 'pipe', 'pipe'],
        });
        // Pipe request body into git process
        req.raw.pipe(proc.stdin);
        proc.stdout.pipe(reply.raw, { end: false });
        proc.stderr.on('data', (data) => {
            fastify.log.warn(`git upload-pack stderr: ${data.toString()}`);
        });
        proc.on('close', () => {
            reply.raw.end();
        });
        proc.on('error', (err) => {
            fastify.log.error(err, 'Failed to spawn git upload-pack');
            if (!reply.raw.headersSent) {
                reply.raw.writeHead(500);
            }
            reply.raw.end();
        });
        return reply;
    });
    // POST /git/:repo/git-receive-pack — push
    fastify.post('/git/:repo/git-receive-pack', async (req, reply) => {
        const { repo } = req.params;
        const auth = authenticateBasic(req.headers.authorization, authService);
        if ('error' in auth) {
            reply.header('WWW-Authenticate', 'Basic realm="Studiograph Git"');
            return reply.status(auth.statusCode).send(auth.error);
        }
        const includePrivate = reqIncludePrivate(req);
        const repoPath = resolveRepoPath(repo, workspaceManager, workspacePath, auth.user, authService, includePrivate);
        if (!repoPath || !isGitRepo(repoPath)) {
            return reply.status(404).send('Repository not found');
        }
        // Push requires folder access (admin → all, member → granted only)
        if (!canAccessFolder(auth.user, repo, authService, workspaceManager, includePrivate)) {
            return reply.status(403).send('Push access denied');
        }
        // Clean-tree guard. receive.denyCurrentBranch=updateInstead would
        // otherwise overwrite uncommitted server-side changes via the
        // working-tree reset that updateInstead performs. Auto-commit any
        // pending state under a system author before accepting the push.
        try {
            const graph = workspaceManager.getGraph(repo);
            const gitService = graph.getGitService();
            if (gitService && gitService.hasUncommittedChanges()) {
                const status = gitService.getStatus();
                const fileCount = status.modified.length + status.added.length + status.deleted.length + status.untracked.length;
                const message = `recovery: commit ${fileCount} pending file(s) before incoming push`;
                const recoveryUser = {
                    id: 'studiograph-recovery',
                    name: 'Studiograph (recovery)',
                    email: 'recovery@studiograph.local',
                };
                gitService.commitAll(message, recoveryUser);
                const { recordCommitAudit } = await import('../commit-audit.js');
                recordCommitAudit(workspacePath, {
                    repo,
                    source: 'recovery',
                    user: recoveryUser,
                    message,
                    fileCount,
                });
                fastify.log.warn(`[git-http] auto-committed ${fileCount} pending file(s) in ${repo} before push`);
            }
        }
        catch (err) {
            fastify.log.warn({ err }, '[git-http] pre-push clean-tree guard failed (continuing)');
        }
        reply.raw.writeHead(200, {
            'Content-Type': 'application/x-git-receive-pack-result',
            'Cache-Control': 'no-cache',
        });
        const proc = spawn('git', ['receive-pack', '--stateless-rpc', repoPath], {
            stdio: ['pipe', 'pipe', 'pipe'],
        });
        req.raw.pipe(proc.stdin);
        proc.stdout.pipe(reply.raw, { end: false });
        proc.stderr.on('data', (data) => {
            fastify.log.warn(`git receive-pack stderr: ${data.toString()}`);
        });
        proc.on('close', (code) => {
            reply.raw.end();
            if (code === 0) {
                wsHub?.broadcast({
                    type: 'repo_sync', repo,
                    source: 'git-push',
                    actor: auth.user?.displayName ?? 'Git',
                    timestamp: new Date().toISOString(),
                });
            }
        });
        proc.on('error', (err) => {
            fastify.log.error(err, 'Failed to spawn git receive-pack');
            if (!reply.raw.headersSent) {
                reply.raw.writeHead(500);
            }
            reply.raw.end();
        });
        return reply;
    });
}
//# sourceMappingURL=git-http.js.map