/**
 * Runway Video Handler (Gen-3 Alpha / Gen-4 Turbo)
 *
 * Async generation: POST /v1/image_to_video → poll /v1/tasks/{id}.
 *
 * @module adapters/video/runwayVideoHandler
 * @see https://docs.dev.runwayml.com/api/
 */
import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
import { VIDEO_ERROR_CODES } from "../../constants/videoErrors.js";
import { logger } from "../../utils/logger.js";
import { sanitizeForLog } from "../../utils/logSanitize.js";
import { safeDownload } from "../../utils/safeFetch.js";
import { VideoError } from "../../utils/videoProcessor.js";
import { MAX_VIDEO_BYTES } from "../../utils/sizeGuard.js";
const DEFAULT_BASE_URL = "https://api.dev.runwayml.com/v1";
const REQUEST_TIMEOUT_MS = 30_000;
const POLL_INTERVAL_MS = 5_000;
const TOTAL_TIMEOUT_MS = 5 * 60_000;
/**
 * Runway Video Handler.
 *
 * Auth: `Authorization: Bearer ${RUNWAY_API_KEY}` + `X-Runway-Version`
 * header. Models: gen3a_turbo (Gen-3 Alpha Turbo, default), gen4_turbo.
 */
export class RunwayVideoHandler {
    maxDurationSeconds = 10;
    supportedAspectRatios = [
        "16:9",
        "9:16",
    ];
    supportedResolutions = [
        "720p",
        "1080p",
    ];
    apiKey;
    baseUrl;
    apiVersion;
    constructor(apiKey) {
        const resolved = (apiKey ?? process.env.RUNWAY_API_KEY ?? "").trim();
        this.apiKey = resolved.length > 0 ? resolved : null;
        this.baseUrl = (process.env.RUNWAY_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
        this.apiVersion = process.env.RUNWAY_API_VERSION ?? "2024-11-06";
    }
    isConfigured() {
        return this.apiKey !== null;
    }
    async generate(image, prompt, options) {
        if (!this.apiKey) {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
                message: "RUNWAY_API_KEY not configured",
                category: ErrorCategory.CONFIGURATION,
                severity: ErrorSeverity.HIGH,
                retriable: false,
            });
        }
        if (options.aspectRatio !== undefined &&
            !this.supportedAspectRatios.includes(options.aspectRatio)) {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.INVALID_INPUT,
                message: `Unsupported aspect ratio for Runway: "${options.aspectRatio}". Supported: ${this.supportedAspectRatios.join(", ")}.`,
                category: ErrorCategory.VALIDATION,
                severity: ErrorSeverity.MEDIUM,
                retriable: false,
                context: {
                    requested: options.aspectRatio,
                    supported: this.supportedAspectRatios,
                },
            });
        }
        const startTime = Date.now();
        const abortSignal = options.abortSignal;
        const taskId = await this.submitTask(image, prompt, options);
        const videoUrl = await this.pollUntilComplete(taskId, abortSignal);
        const buffer = await this.downloadVideo(videoUrl);
        const processingTime = Date.now() - startTime;
        logger.info(`[RunwayVideoHandler] Generated ${buffer.length} bytes in ${processingTime}ms — task ${taskId}`);
        return {
            data: buffer,
            mediaType: "video/mp4",
            metadata: {
                duration: options.length ?? 4,
                dimensions: this.calculateDimensions(options),
                model: options.model ?? "gen3a_turbo",
                provider: "runway",
                aspectRatio: options.aspectRatio ?? "16:9",
                audioEnabled: false,
                processingTime,
            },
        };
    }
    async submitTask(image, prompt, options) {
        const dataUri = `data:image/${this.detectImageType(image)};base64,${image.toString("base64")}`;
        const body = {
            model: options.model ?? "gen3a_turbo",
            promptImage: dataUri,
            promptText: prompt,
            duration: options.length ?? 4,
            ratio: options.aspectRatio === "9:16" ? "9:16" : "16:9",
            seed: undefined,
        };
        const response = await this.fetchWithTimeout(`${this.baseUrl}/image_to_video`, {
            method: "POST",
            headers: {
                Authorization: `Bearer ${this.apiKey}`,
                "Content-Type": "application/json",
                "X-Runway-Version": this.apiVersion,
            },
            body: JSON.stringify(body),
        });
        if (!response.ok) {
            const raw = await response.text();
            const retriable = response.status === 408 ||
                response.status === 429 ||
                response.status >= 500;
            throw new VideoError({
                code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                message: `Runway submit failed: ${response.status} — ${sanitizeForLog(raw, 500)}`,
                category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION,
                severity: ErrorSeverity.HIGH,
                retriable,
                context: { status: response.status },
            });
        }
        const json = (await response.json());
        if (!json.id) {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                message: "Runway submit response missing id",
                category: ErrorCategory.EXECUTION,
                severity: ErrorSeverity.HIGH,
                retriable: false,
                context: { response: json },
            });
        }
        return json.id;
    }
    async pollUntilComplete(taskId, abortSignal) {
        const startTime = Date.now();
        while (Date.now() - startTime < TOTAL_TIMEOUT_MS) {
            if (abortSignal?.aborted) {
                throw new VideoError({
                    code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                    message: `Runway poll for task ${taskId} aborted by caller`,
                    category: ErrorCategory.NETWORK,
                    severity: ErrorSeverity.MEDIUM,
                    retriable: false,
                    context: { taskId },
                });
            }
            const response = await this.fetchWithTimeout(`${this.baseUrl}/tasks/${taskId}`, {
                method: "GET",
                headers: {
                    Authorization: `Bearer ${this.apiKey}`,
                    "X-Runway-Version": this.apiVersion,
                },
            }, abortSignal);
            if (!response.ok) {
                const raw = await response.text();
                throw new VideoError({
                    code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                    message: `Runway poll failed: ${response.status} — ${sanitizeForLog(raw, 500)}`,
                    category: ErrorCategory.NETWORK,
                    severity: ErrorSeverity.MEDIUM,
                    retriable: response.status >= 500,
                });
            }
            const data = (await response.json());
            if (data.status === "SUCCEEDED" || data.status === "succeeded") {
                const url = Array.isArray(data.output) ? data.output[0] : data.output;
                if (typeof url !== "string") {
                    throw new VideoError({
                        code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                        message: `Runway task ${taskId} completed but no output URL`,
                        category: ErrorCategory.EXECUTION,
                        severity: ErrorSeverity.HIGH,
                        retriable: false,
                        context: { taskId, data },
                    });
                }
                return url;
            }
            if (data.status === "FAILED" || data.status === "failed") {
                throw new VideoError({
                    code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                    message: `Runway task ${taskId} failed: ${data.failure ?? data.error ?? "unknown"}`,
                    category: ErrorCategory.EXECUTION,
                    severity: ErrorSeverity.HIGH,
                    retriable: false,
                    context: { taskId, data },
                });
            }
            // Abortable sleep.
            await new Promise((resolve, reject) => {
                const onAbort = () => {
                    clearTimeout(timer);
                    reject(new VideoError({
                        code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                        message: `Runway poll for task ${taskId} aborted by caller`,
                        category: ErrorCategory.NETWORK,
                        severity: ErrorSeverity.MEDIUM,
                        retriable: false,
                        context: { taskId },
                    }));
                };
                const timer = setTimeout(() => {
                    abortSignal?.removeEventListener("abort", onAbort);
                    resolve();
                }, POLL_INTERVAL_MS);
                abortSignal?.addEventListener("abort", onAbort, { once: true });
            });
        }
        throw new VideoError({
            code: VIDEO_ERROR_CODES.POLL_TIMEOUT,
            message: `Runway task ${taskId} did not complete within ${TOTAL_TIMEOUT_MS / 1000}s`,
            category: ErrorCategory.TIMEOUT,
            severity: ErrorSeverity.MEDIUM,
            retriable: true,
            context: { taskId },
        });
    }
    async downloadVideo(url) {
        try {
            return await safeDownload(url, {
                maxBytes: MAX_VIDEO_BYTES,
                label: "Runway video",
                timeoutMs: REQUEST_TIMEOUT_MS,
            });
        }
        catch (err) {
            throw new VideoError({
                code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                message: `Runway video download rejected: ${err instanceof Error ? err.message : String(err)}`,
                category: ErrorCategory.NETWORK,
                severity: ErrorSeverity.HIGH,
                retriable: false,
                context: { url },
                originalError: err instanceof Error ? err : undefined,
            });
        }
    }
    async fetchWithTimeout(url, init, callerAbortSignal) {
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
        const onCallerAbort = () => controller.abort();
        callerAbortSignal?.addEventListener("abort", onCallerAbort, { once: true });
        try {
            return await fetch(url, { ...init, signal: controller.signal });
        }
        catch (err) {
            if (err instanceof Error && err.name === "AbortError") {
                throw new VideoError({
                    code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                    message: `Runway request to ${url} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
                    category: ErrorCategory.NETWORK,
                    severity: ErrorSeverity.HIGH,
                    retriable: true,
                    originalError: err,
                });
            }
            throw new VideoError({
                code: VIDEO_ERROR_CODES.GENERATION_FAILED,
                message: `Runway fetch failed: ${err instanceof Error ? err.message : String(err)}`,
                category: ErrorCategory.NETWORK,
                severity: ErrorSeverity.HIGH,
                retriable: true,
                originalError: err instanceof Error ? err : undefined,
            });
        }
        finally {
            callerAbortSignal?.removeEventListener("abort", onCallerAbort);
            clearTimeout(timeoutId);
        }
    }
    detectImageType(buffer) {
        if (buffer.length < 4) {
            return "jpeg";
        }
        if (buffer[0] === 0x89 && buffer[1] === 0x50) {
            return "png";
        }
        if (buffer[0] === 0xff && buffer[1] === 0xd8) {
            return "jpeg";
        }
        // RIFF container: check offset 8 to distinguish WebP from WAV so audio
        // data passed as image is not silently misidentified as WebP.
        if (buffer.length >= 12 &&
            buffer[0] === 0x52 &&
            buffer[1] === 0x49 &&
            buffer[2] === 0x46 &&
            buffer[3] === 0x46) {
            if (buffer[8] === 0x57 &&
                buffer[9] === 0x45 &&
                buffer[10] === 0x42 &&
                buffer[11] === 0x50) {
                return "webp";
            }
            // RIFF but not WEBP (e.g. WAVE audio) — not a valid image.
            return "jpeg";
        }
        return "jpeg";
    }
    calculateDimensions(options) {
        const aspectRatio = options.aspectRatio ?? "16:9";
        const resolution = options.resolution ?? "720p";
        if (resolution === "1080p") {
            return aspectRatio === "9:16"
                ? { width: 1080, height: 1920 }
                : { width: 1920, height: 1080 };
        }
        return aspectRatio === "9:16"
            ? { width: 720, height: 1280 }
            : { width: 1280, height: 720 };
    }
}
