/**
 * R2 management API routes — owner-only operations that live alongside
 * the existing `/api/settings` + `/api/secrets` flow.
 *
 *   GET  /api/r2/status                — local-asset count + current mode
 *   POST /api/r2/create-bucket         — create the configured bucket (idempotent)
 *   POST /api/r2/sync                  — start a local-asset upload job
 *   GET  /api/r2/sync/:jobId           — poll progress
 *   POST /api/r2/sync/:jobId/cancel    — request cooperative cancel
 *
 * Credentials are read from the encrypted store (`r2.account_id` setting +
 * `r2.access_key_id`/`r2.secret_access_key` secrets) — the body never
 * carries them on the wire. The user is expected to have saved each
 * field via the existing settings UI before invoking these endpoints.
 *
 * Sync runs in-process. Jobs are tracked in a `Map<jobId, SyncJob>` and
 * survive only as long as the process. After completion (or cancel) the
 * job stays around for SYNC_JOB_TTL_MS so the UI can fetch the final
 * state, then auto-evicts.
 */
import { randomUUID } from 'crypto';
import { join, relative } from 'path';
import { isWorkspaceOwner } from '../../services/auth-service.js';
import { tryResolve } from '../../core/secrets/read-flip.js';
import { GitService } from '../../services/git.js';
import { countLocalAssets, createR2Bucket, syncLocalAssetsToR2, } from '../../services/assets/r2-sync.js';
const SYNC_JOB_TTL_MS = 10 * 60_000;
const syncJobs = new Map();
function reapStaleJobs() {
    const now = Date.now();
    for (const [id, job] of syncJobs) {
        if (job.finishedAt && now - job.finishedAt > SYNC_JOB_TTL_MS) {
            syncJobs.delete(id);
        }
    }
}
function requireOwner(req, authService) {
    if (!authService.hasUsers())
        return true;
    const user = req.user;
    if (!user)
        return true; // API key — admin-equivalent
    return isWorkspaceOwner(user.role);
}
function actorLabel(req) {
    const user = req.user;
    return user ? user.displayName : 'api-token';
}
/**
 * Read the R2 fields from the encrypted store. Returns the resolved
 * values plus a list of missing field names; callers can decide which
 * subset they need (createBucket doesn't need public_url; sync needs
 * everything).
 */
function resolveR2() {
    const accountId = tryResolve('r2.account_id') ?? '';
    const bucket = tryResolve('r2.bucket') ?? '';
    const publicUrl = tryResolve('r2.public_url') ?? '';
    const accessKeyId = tryResolve('r2.access_key_id') ?? '';
    const secretAccessKey = tryResolve('r2.secret_access_key') ?? '';
    const missing = [];
    if (!accountId)
        missing.push('r2.account_id');
    if (!bucket)
        missing.push('r2.bucket');
    if (!accessKeyId)
        missing.push('r2.access_key_id');
    if (!secretAccessKey)
        missing.push('r2.secret_access_key');
    return {
        resolved: { accountId, bucket, publicUrl, accessKeyId, secretAccessKey },
        missing,
    };
}
export async function registerR2ApiRoutes(fastify, workspaceManager, authService, gitUser) {
    // ── GET /api/r2/status ──────────────────────────────────────────────────
    //
    // Reports whether the workspace currently runs in `r2` mode (account_id
    // + bucket resolve) and counts files under `.studiograph/assets/`. The
    // Settings UI uses this to gate the "you have N local assets, sync to
    // R2 →" banner.
    fastify.get('/api/r2/status', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        const workspacePath = workspaceManager.getWorkspacePath();
        const accountId = tryResolve('r2.account_id') ?? '';
        const bucket = tryResolve('r2.bucket') ?? '';
        const mode = accountId && bucket ? 'r2' : 'local';
        const localAssetsCount = countLocalAssets(workspacePath);
        return reply.send({ mode, localAssetsCount });
    });
    // ── POST /api/r2/create-bucket ──────────────────────────────────────────
    //
    // Idempotent bucket-create. Reads credentials + bucket name from the
    // encrypted store — the body carries no secrets. The CLI shape
    // (`r2 setup`) does the same thing inline; this endpoint surfaces it
    // to the UI so users don't have to drop to a terminal after entering
    // credentials in Settings.
    fastify.post('/api/r2/create-bucket', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        const { resolved, missing } = resolveR2();
        // public_url is not required to create the bucket itself.
        const requiredMissing = missing.filter(k => k !== 'r2.public_url');
        if (requiredMissing.length > 0) {
            return reply.status(400).send({
                error: `Missing R2 fields: ${requiredMissing.join(', ')}. Save them under Settings → Providers → Asset Storage first.`,
            });
        }
        try {
            const result = await createR2Bucket({
                accountId: resolved.accountId,
                accessKeyId: resolved.accessKeyId,
                secretAccessKey: resolved.secretAccessKey,
            }, resolved.bucket);
            return reply.send({ ok: true, ...result });
        }
        catch (err) {
            // S3 SDK errors have `.name` (e.g. InvalidAccessKeyId) and `.message`.
            // The UI renders message verbatim — keep it actionable, no stack.
            const name = err?.name ?? 'Error';
            const message = err?.message ?? 'Failed to create bucket';
            return reply.status(400).send({ error: `${name}: ${message}` });
        }
    });
    // ── POST /api/r2/sync ───────────────────────────────────────────────────
    //
    // Kicks off a background sync, returns `{ jobId }`. Progress is polled
    // via GET /api/r2/sync/:jobId. Refuses if another sync is already in
    // flight — concurrent syncs would race on file rewrites.
    fastify.post('/api/r2/sync', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        reapStaleJobs();
        for (const job of syncJobs.values()) {
            if (!job.finishedAt) {
                return reply.status(409).send({
                    error: 'A sync is already running',
                    jobId: job.id,
                });
            }
        }
        const { resolved, missing } = resolveR2();
        if (missing.length > 0 || !resolved.publicUrl) {
            const all = missing.includes('r2.public_url') ? missing : [...missing, ...(resolved.publicUrl ? [] : ['r2.public_url'])];
            return reply.status(400).send({
                error: `Missing R2 fields: ${all.join(', ')}. Complete the Asset Storage form before running sync.`,
            });
        }
        const repoPaths = workspaceManager.getAllRepoConfigs()
            .map(r => join(workspaceManager.getWorkspacePath(), r.path));
        const job = {
            id: randomUUID(),
            progress: {
                totalFiles: 0,
                uploadedFiles: 0,
                rewrittenFiles: 0,
                failedFiles: 0,
                currentFile: '',
                phase: 'scanning',
                errors: [],
            },
            startedAt: Date.now(),
            cancelRequested: false,
            startedBy: actorLabel(req),
        };
        syncJobs.set(job.id, job);
        // Author the commits as the user who clicked the button when we have
        // an AuthUser; fall back to the workspace gitUser for API-key callers
        // and unauthenticated/open-mode runs. Matches what /api/repos/*
        // mutation routes do for entity edits.
        const reqUser = req.user;
        const commitAuthor = reqUser
            ? { id: String(reqUser.id), name: reqUser.displayName, email: reqUser.email }
            : gitUser;
        // Fire-and-forget. The promise's result is captured into the job's
        // final progress state so the polling endpoint can render the
        // outcome without the caller having to await here.
        (async () => {
            try {
                const result = await syncLocalAssetsToR2({
                    workspacePath: workspaceManager.getWorkspacePath(),
                    repoPaths,
                    credentials: {
                        accountId: resolved.accountId,
                        accessKeyId: resolved.accessKeyId,
                        secretAccessKey: resolved.secretAccessKey,
                    },
                    bucket: resolved.bucket,
                    publicUrl: resolved.publicUrl,
                    onProgress: (p) => { job.progress = p; },
                    shouldCancel: () => job.cancelRequested,
                });
                // Commit pass-2 markdown rewrites through the git pipeline. Without
                // this the repos stay dirty after a sync — the recovery sweep would
                // eventually catch them, but the proper mutation surface is one
                // commit per repo, attributed to the requesting user. Mirrors what
                // the CLI's `r2 migrate-urls` does. Per-repo failures are surfaced
                // through the job's error list and don't abort the rest.
                for (const [repoPath, files] of result.rewrittenByRepo) {
                    if (files.length === 0)
                        continue;
                    try {
                        const git = new GitService(repoPath);
                        const txn = git.createTransaction();
                        for (const file of files) {
                            txn.add({
                                operation: 'update',
                                filePath: relative(repoPath, file),
                                entityType: '',
                                entityId: '',
                            });
                        }
                        await txn.commit(commitAuthor, 'chore(assets): rewrite asset references to R2');
                    }
                    catch (err) {
                        job.progress = {
                            ...job.progress,
                            errors: [
                                ...job.progress.errors,
                                { file: repoPath, message: `Commit failed: ${err?.message ?? err}` },
                            ],
                        };
                    }
                }
            }
            catch (err) {
                job.progress = {
                    ...job.progress,
                    phase: 'error',
                    errors: [
                        ...job.progress.errors,
                        { file: '', message: err?.message ?? 'Sync failed' },
                    ],
                };
            }
            finally {
                job.finishedAt = Date.now();
            }
        })();
        return reply.send({ jobId: job.id });
    });
    // ── GET /api/r2/sync/:jobId ─────────────────────────────────────────────
    fastify.get('/api/r2/sync/:jobId', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        reapStaleJobs();
        const job = syncJobs.get(req.params.jobId);
        if (!job)
            return reply.status(404).send({ error: 'Job not found or expired' });
        return reply.send({
            jobId: job.id,
            startedAt: job.startedAt,
            finishedAt: job.finishedAt ?? null,
            progress: job.progress,
        });
    });
    // ── POST /api/r2/sync/:jobId/cancel ─────────────────────────────────────
    fastify.post('/api/r2/sync/:jobId/cancel', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        const job = syncJobs.get(req.params.jobId);
        if (!job)
            return reply.status(404).send({ error: 'Job not found or expired' });
        if (job.finishedAt)
            return reply.send({ ok: true, alreadyFinished: true });
        job.cancelRequested = true;
        return reply.send({ ok: true });
    });
}
//# sourceMappingURL=r2-api.js.map