/**
 * Shared R2 management service — bucket creation + local-asset sync.
 *
 * Lifted out of `src/cli/commands/r2.ts` so the CLI and the HTTP API
 * (`/api/r2/*`) share one implementation. The CLI handlers in r2.ts
 * still own user-facing prompts (clack), spinners, and process.exit;
 * this module just runs the underlying S3 work and emits progress
 * through a callback so each surface can render it natively.
 */
import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
import { join, relative, resolve, extname, dirname } from 'path';
import { S3Client, CreateBucketCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
// ── Content type map ──────────────────────────────────────────────────────
const CONTENT_TYPES = {
    '.woff': 'font/woff',
    '.woff2': 'font/woff2',
    '.ttf': 'font/ttf',
    '.otf': 'font/otf',
    '.eot': 'application/vnd.ms-fontobject',
    '.svg': 'image/svg+xml',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.webp': 'image/webp',
    '.gif': 'image/gif',
    '.pdf': 'application/pdf',
    '.js': 'application/javascript',
    '.css': 'text/css',
    '.json': 'application/json',
};
export function contentType(filePath) {
    return CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
}
// ── Local asset detection ─────────────────────────────────────────────────
const ASSET_EXTS = new Set([
    '.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg',
    '.mp4', '.webm', '.mov',
    '.pdf',
]);
function isLocalAssetRef(ref) {
    if (ref.startsWith('http://') ||
        ref.startsWith('https://') ||
        ref.startsWith('data:') ||
        ref.startsWith('#'))
        return false;
    const ext = extname(ref.split('?')[0]).toLowerCase();
    return ASSET_EXTS.has(ext);
}
export function walkDir(dir) {
    const files = [];
    for (const entry of readdirSync(dir)) {
        const full = join(dir, entry);
        if (statSync(full).isDirectory()) {
            files.push(...walkDir(full));
        }
        else {
            files.push(full);
        }
    }
    return files;
}
export function buildR2Client(c) {
    return new S3Client({
        region: 'auto',
        endpoint: `https://${c.accountId}.r2.cloudflarestorage.com`,
        credentials: {
            accessKeyId: c.accessKeyId,
            secretAccessKey: c.secretAccessKey,
        },
    });
}
/**
 * Create an R2 bucket. Idempotent: BucketAlreadyExists /
 * BucketAlreadyOwnedByYou / 409 are treated as success with
 * alreadyExists=true.
 */
export async function createR2Bucket(creds, bucket) {
    const client = buildR2Client(creds);
    try {
        await client.send(new CreateBucketCommand({ Bucket: bucket }));
        return { bucket, alreadyExists: false };
    }
    catch (e) {
        if (e?.name === 'BucketAlreadyExists' ||
            e?.name === 'BucketAlreadyOwnedByYou' ||
            e?.$metadata?.httpStatusCode === 409) {
            return { bucket, alreadyExists: true };
        }
        throw e;
    }
}
/**
 * Run a full local-asset sync: tokens.json files under
 * .studiograph/apps/*​/theme/ plus markdown references across every
 * repo. Mirrors the legacy CLI command's two-pass shape; the progress
 * callback gets fired after every per-file step so the UI can render
 * smoothly.
 *
 * Cancellation is cooperative — the loop checks `shouldCancel()` between
 * files. In-flight uploads complete (they're per-file Promise.all batches
 * inside each markdown file).
 */
export async function syncLocalAssetsToR2(opts) {
    const { workspacePath, repoPaths, credentials, bucket, publicUrl, onProgress, shouldCancel } = opts;
    const client = buildR2Client(credentials);
    const r2 = { bucket, public_url: publicUrl };
    const progress = {
        totalFiles: 0,
        uploadedFiles: 0,
        rewrittenFiles: 0,
        failedFiles: 0,
        currentFile: '',
        phase: 'scanning',
        errors: [],
    };
    const emit = () => onProgress?.({ ...progress, errors: [...progress.errors] });
    // ── Pass 1: tokens.json files ───────────────────────────────────────────
    const appsDir = join(workspacePath, '.studiograph', 'apps');
    const tokensFiles = [];
    if (existsSync(appsDir)) {
        for (const app of readdirSync(appsDir)) {
            const tokensPath = join(appsDir, app, 'theme', 'tokens.json');
            if (existsSync(tokensPath))
                tokensFiles.push(tokensPath);
        }
    }
    const tokensJobs = [];
    for (const tokensPath of tokensFiles) {
        let count = 0;
        try {
            const raw = readFileSync(tokensPath, 'utf8');
            const tokens = JSON.parse(raw);
            const visit = (obj) => {
                if (typeof obj === 'string' && obj.startsWith('file:'))
                    count++;
                else if (obj && typeof obj === 'object')
                    for (const k of Object.keys(obj))
                        visit(obj[k]);
            };
            visit(tokens);
        }
        catch { /* malformed tokens.json — pass-2 will surface */ }
        tokensJobs.push({ path: tokensPath, refs: count });
        progress.totalFiles += count;
    }
    const mdJobs = [];
    for (const repoPath of repoPaths) {
        if (!existsSync(repoPath))
            continue;
        const mdFiles = walkDir(repoPath).filter(f => f.endsWith('.md'));
        for (const mdFile of mdFiles) {
            const original = readFileSync(mdFile, 'utf8');
            const refs = new Set();
            for (const m of original.matchAll(/!?\[[^\]]*\]\(([^)\s]+)[^)]*\)/g)) {
                if (isLocalAssetRef(m[1]))
                    refs.add(m[1]);
            }
            for (const m of original.matchAll(/\bsrc=["']([^"']+)["']/g)) {
                if (isLocalAssetRef(m[1]))
                    refs.add(m[1]);
            }
            if (refs.size === 0)
                continue;
            mdJobs.push({ mdFile, repoPath, refs: [...refs] });
            progress.totalFiles += refs.size;
        }
    }
    const assetJobs = [];
    const assetsRoot = join(workspacePath, '.studiograph', 'assets');
    if (existsSync(assetsRoot)) {
        for (const typeDir of readdirSync(assetsRoot)) {
            const typePath = join(assetsRoot, typeDir);
            if (!statSync(typePath).isDirectory())
                continue;
            for (const idDir of readdirSync(typePath)) {
                const idPath = join(typePath, idDir);
                if (!statSync(idPath).isDirectory())
                    continue;
                for (const filename of readdirSync(idPath)) {
                    const filePath = join(idPath, filename);
                    if (!statSync(filePath).isFile())
                        continue;
                    assetJobs.push({
                        localPath: filePath,
                        key: `${typeDir}/${idDir}/${filename}`,
                    });
                }
            }
        }
        progress.totalFiles += assetJobs.length;
    }
    // Per-repo list of markdown files we rewrote, returned to the caller for
    // git-pipeline commits. Built incrementally during pass 2.
    const rewrittenByRepo = new Map();
    progress.phase = 'uploading';
    emit();
    let cancelled = false;
    const checkCancel = () => {
        if (shouldCancel?.()) {
            cancelled = true;
            return true;
        }
        return false;
    };
    // ── Pass 1: upload tokens.json file: references ─────────────────────────
    for (const job of tokensJobs) {
        if (checkCancel())
            break;
        if (job.refs === 0)
            continue;
        let tokens;
        try {
            tokens = JSON.parse(readFileSync(job.path, 'utf8'));
        }
        catch (err) {
            progress.errors.push({ file: job.path, message: `Parse failed: ${err?.message ?? err}` });
            progress.failedFiles += job.refs;
            emit();
            continue;
        }
        let modified = false;
        const rewrite = async (obj, basePath) => {
            if (typeof obj === 'string' && obj.startsWith('file:')) {
                const localPath = join(basePath, obj.slice(5));
                const key = relative(workspacePath, localPath);
                if (!existsSync(localPath)) {
                    progress.errors.push({ file: key, message: 'Referenced file not found' });
                    progress.failedFiles++;
                    emit();
                    return obj;
                }
                progress.currentFile = key;
                emit();
                try {
                    await client.send(new PutObjectCommand({
                        Bucket: r2.bucket,
                        Key: key,
                        Body: readFileSync(localPath),
                        ContentType: contentType(localPath),
                        CacheControl: 'public, max-age=31536000, immutable',
                    }));
                    progress.uploadedFiles++;
                    modified = true;
                    emit();
                    return `${r2.public_url}/${key}`;
                }
                catch (err) {
                    progress.errors.push({ file: key, message: err?.message ?? 'Upload failed' });
                    progress.failedFiles++;
                    emit();
                    return obj;
                }
            }
            if (obj && typeof obj === 'object') {
                const out = Array.isArray(obj) ? [] : {};
                for (const k of Object.keys(obj))
                    out[k] = await rewrite(obj[k], basePath);
                return out;
            }
            return obj;
        };
        const updated = await rewrite(tokens, workspacePath);
        if (modified) {
            writeFileSync(job.path, JSON.stringify(updated, null, 2) + '\n');
            progress.rewrittenFiles++;
            emit();
        }
    }
    // ── Pass 2: upload markdown asset refs ──────────────────────────────────
    for (const job of mdJobs) {
        if (checkCancel())
            break;
        const original = readFileSync(job.mdFile, 'utf8');
        const replacements = new Map();
        for (const ref of job.refs) {
            if (checkCancel())
                break;
            const absPath = resolve(dirname(job.mdFile), ref);
            const key = relative(workspacePath, absPath);
            if (!existsSync(absPath)) {
                progress.errors.push({ file: key, message: 'Referenced file not found' });
                progress.failedFiles++;
                emit();
                continue;
            }
            progress.currentFile = key;
            emit();
            try {
                await client.send(new PutObjectCommand({
                    Bucket: r2.bucket,
                    Key: key,
                    Body: readFileSync(absPath),
                    ContentType: contentType(absPath),
                    CacheControl: 'public, max-age=31536000, immutable',
                }));
                replacements.set(ref, `${r2.public_url}/${key}`);
                progress.uploadedFiles++;
                emit();
            }
            catch (err) {
                progress.errors.push({ file: key, message: err?.message ?? 'Upload failed' });
                progress.failedFiles++;
                emit();
            }
        }
        if (replacements.size > 0) {
            let updated = original;
            for (const [ref, r2Url] of replacements) {
                const escaped = ref.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
                updated = updated.replace(new RegExp(escaped, 'g'), r2Url);
            }
            if (updated !== original) {
                writeFileSync(job.mdFile, updated);
                progress.rewrittenFiles++;
                const repoFiles = rewrittenByRepo.get(job.repoPath) ?? [];
                repoFiles.push(job.mdFile);
                rewrittenByRepo.set(job.repoPath, repoFiles);
                emit();
            }
        }
    }
    // ── Pass 3: migrate .studiograph/assets/{type}/{id}/{filename} → R2 ─────
    // Upload each local-backend file to R2 with the same key, then delete the
    // local copy. After flip to r2 mode, `/api/assets/*` resolves these via
    // R2.getSignedUrl — without this pass the proxied URLs 404 because R2
    // doesn't have the key the local backend wrote.
    for (const job of assetJobs) {
        if (checkCancel())
            break;
        progress.currentFile = job.key;
        emit();
        try {
            await client.send(new PutObjectCommand({
                Bucket: r2.bucket,
                Key: job.key,
                Body: readFileSync(job.localPath),
                ContentType: contentType(job.localPath),
                CacheControl: 'public, max-age=31536000, immutable',
            }));
            progress.uploadedFiles++;
            emit();
            // Only delete after the upload resolved successfully. If the unlink
            // itself trips (read-only filesystem, race), record it but don't
            // re-fail the upload — the file is in R2; the worst case is the
            // banner counter doesn't decrement and the user sees one more
            // "sync" prompt.
            try {
                unlinkSync(job.localPath);
            }
            catch (err) {
                progress.errors.push({
                    file: job.key,
                    message: `Uploaded to R2 but local copy not deletable: ${err?.message ?? err}`,
                });
                emit();
            }
        }
        catch (err) {
            progress.errors.push({ file: job.key, message: err?.message ?? 'Upload failed' });
            progress.failedFiles++;
            emit();
        }
    }
    progress.currentFile = '';
    progress.phase = cancelled ? 'cancelled' : 'done';
    emit();
    return {
        uploaded: progress.uploadedFiles,
        rewritten: progress.rewrittenFiles,
        failed: progress.failedFiles,
        cancelled,
        rewrittenByRepo,
    };
}
// ── Local asset count (for "you have N local assets" banner) ──────────────
/**
 * Count files under `.studiograph/assets/`. Used by the UI banner to tell
 * the operator there's work to migrate after switching from local to R2.
 * Returns 0 if the directory doesn't exist.
 */
export function countLocalAssets(workspacePath) {
    const assetsDir = join(workspacePath, '.studiograph', 'assets');
    if (!existsSync(assetsDir))
        return 0;
    let count = 0;
    const visit = (dir) => {
        let entries;
        try {
            entries = readdirSync(dir);
        }
        catch {
            return;
        }
        for (const entry of entries) {
            const full = join(dir, entry);
            let st;
            try {
                st = statSync(full);
            }
            catch {
                continue;
            }
            if (st.isDirectory())
                visit(full);
            else if (st.isFile())
                count++;
        }
    };
    visit(assetsDir);
    return count;
}
//# sourceMappingURL=r2-sync.js.map