/**
 * HeyGen Avatar Handler
 *
 * Async talking-head generation. Submits a video.generate request, polls
 * the video status, downloads the result MP4.
 *
 * @module avatar/providers/HeyGenAvatar
 * @see https://docs.heygen.com/reference/avatar-video
 */
import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
import { AVATAR_ERROR_CODES, AvatarError, } from "../../utils/avatarProcessor.js";
import { logger } from "../../utils/logger.js";
import { sanitizeForLog } from "../../utils/logSanitize.js";
import { safeDownload } from "../../utils/safeFetch.js";
import { MAX_VIDEO_BYTES } from "../../utils/sizeGuard.js";
const DEFAULT_BASE_URL = "https://api.heygen.com/v2";
const REQUEST_TIMEOUT_MS = 30_000;
const POLL_INTERVAL_MS = 5_000;
const TOTAL_TIMEOUT_MS = 5 * 60_000;
/**
 * HeyGen Avatar Handler.
 *
 * Auth: `X-API-Key: ${HEYGEN_API_KEY}`. The HeyGen API expects an
 * `avatar_id` (HeyGen's own avatar catalog) — pass it via `options.voice`
 * for legacy callers, or `options.avatarId` for explicit users.
 */
export class HeyGenAvatar {
    maxAudioDurationSeconds = 300; // 5 minutes
    supportedFormats = ["mp4"];
    apiKey;
    baseUrl;
    constructor(apiKey) {
        const resolved = (apiKey ?? process.env.HEYGEN_API_KEY ?? "").trim();
        this.apiKey = resolved.length > 0 ? resolved : null;
        this.baseUrl = (process.env.HEYGEN_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
    }
    isConfigured() {
        return this.apiKey !== null;
    }
    async generate(options) {
        if (!this.apiKey) {
            throw new AvatarError({
                code: AVATAR_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
                message: "HEYGEN_API_KEY not configured",
                category: ErrorCategory.CONFIGURATION,
                severity: ErrorSeverity.HIGH,
                retriable: false,
            });
        }
        if (!options.text && !options.audio) {
            throw new AvatarError({
                code: AVATAR_ERROR_CODES.AUDIO_REQUIRED,
                message: "HeyGen requires either `text` or `audio` to drive the avatar",
                category: ErrorCategory.VALIDATION,
                severity: ErrorSeverity.MEDIUM,
                retriable: false,
            });
        }
        const startTime = Date.now();
        const abortSignal = options.abortSignal;
        const videoId = await this.submitVideo(options);
        const videoUrl = await this.pollUntilComplete(videoId, abortSignal);
        const buffer = await this.download(videoUrl);
        const latency = Date.now() - startTime;
        logger.info(`[HeyGenAvatar] Generated ${buffer.length} bytes in ${latency}ms — video ${videoId}`);
        return {
            buffer,
            format: "mp4",
            size: buffer.length,
            provider: "heygen",
            metadata: {
                latency,
                provider: "heygen",
                jobId: videoId,
            },
        };
    }
    async submitVideo(options) {
        const heyOpts = options;
        const avatarId = heyOpts.avatarId ??
            (typeof options.image === "string" &&
                /^[a-zA-Z0-9_-]{20,}$/.test(options.image)
                ? options.image // use image string as avatar_id when it looks like one
                : undefined);
        if (!avatarId) {
            throw new AvatarError({
                code: AVATAR_ERROR_CODES.INVALID_INPUT,
                message: "HeyGen requires `avatarId` (HeyGen avatar catalog id). Pass via options.avatarId or as options.image with a valid HeyGen id.",
                category: ErrorCategory.VALIDATION,
                severity: ErrorSeverity.MEDIUM,
                retriable: false,
            });
        }
        if (options.audio !== undefined) {
            if (Buffer.isBuffer(options.audio)) {
                throw new AvatarError({
                    code: AVATAR_ERROR_CODES.INVALID_INPUT,
                    message: "HeyGen requires a publicly accessible audio URL; got a binary Buffer. Upload the audio to a hosted location and pass the HTTPS URL instead.",
                    category: ErrorCategory.VALIDATION,
                    severity: ErrorSeverity.MEDIUM,
                    retriable: false,
                });
            }
            if (typeof options.audio !== "string" ||
                !/^https?:\/\//i.test(options.audio)) {
                throw new AvatarError({
                    code: AVATAR_ERROR_CODES.INVALID_INPUT,
                    message: "HeyGen requires a publicly accessible HTTPS audio URL; got an unsupported audio input type. Upload the audio to a hosted location and pass the HTTPS URL instead.",
                    category: ErrorCategory.VALIDATION,
                    severity: ErrorSeverity.MEDIUM,
                    retriable: false,
                });
            }
        }
        const voiceConfig = options.audio
            ? {
                type: "audio",
                audio_url: options.audio,
            }
            : {
                type: "text",
                input_text: options.text,
                voice_id: options.voice ?? "1bd001e7e50f421d891986aad5158bc8",
            };
        const body = {
            video_inputs: [
                {
                    character: {
                        type: "avatar",
                        avatar_id: avatarId,
                        avatar_style: "normal",
                    },
                    voice: voiceConfig,
                    background: {
                        type: "color",
                        value: heyOpts.backgroundColor ?? "#FFFFFF",
                    },
                },
            ],
            dimension: {
                width: heyOpts.width ?? 1280,
                height: heyOpts.height ?? 720,
            },
        };
        const response = await this.fetchWithTimeout(`${this.baseUrl}/video/generate`, {
            method: "POST",
            headers: {
                "X-API-Key": this.apiKey,
                "Content-Type": "application/json",
            },
            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 AvatarError({
                code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                message: `HeyGen 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());
        const videoId = json.data?.video_id;
        if (!videoId) {
            throw new AvatarError({
                code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                message: "HeyGen submit response missing video_id",
                category: ErrorCategory.EXECUTION,
                severity: ErrorSeverity.HIGH,
                retriable: false,
                context: { response: json },
            });
        }
        return videoId;
    }
    async pollUntilComplete(videoId, abortSignal) {
        const startTime = Date.now();
        // HeyGen status endpoint is on v1, not v2.
        const statusBaseUrl = this.baseUrl.replace(/\/v2$/, "/v1");
        while (Date.now() - startTime < TOTAL_TIMEOUT_MS) {
            if (abortSignal?.aborted) {
                throw new AvatarError({
                    code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                    message: `HeyGen poll for video ${videoId} aborted by caller`,
                    category: ErrorCategory.NETWORK,
                    severity: ErrorSeverity.MEDIUM,
                    retriable: false,
                    context: { videoId },
                });
            }
            const response = await this.fetchWithTimeout(`${statusBaseUrl}/video_status.get?video_id=${videoId}`, {
                method: "GET",
                headers: { "X-API-Key": this.apiKey },
            }, abortSignal);
            if (!response.ok) {
                const raw = await response.text();
                throw new AvatarError({
                    code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                    message: `HeyGen poll failed: ${response.status} — ${sanitizeForLog(raw, 500)}`,
                    category: ErrorCategory.NETWORK,
                    severity: ErrorSeverity.MEDIUM,
                    retriable: response.status >= 500,
                    context: { status: response.status, videoId },
                });
            }
            const data = (await response.json());
            if (data.data?.status === "completed") {
                const videoUrl = data.data.video_url;
                if (!videoUrl) {
                    throw new AvatarError({
                        code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                        message: `HeyGen video ${videoId} completed but no URL returned`,
                        category: ErrorCategory.EXECUTION,
                        severity: ErrorSeverity.HIGH,
                        retriable: false,
                        context: { videoId, data },
                    });
                }
                return videoUrl;
            }
            if (data.data?.status === "failed") {
                throw new AvatarError({
                    code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                    message: `HeyGen video ${videoId} failed: ${data.data?.error?.message ?? "unknown"}`,
                    category: ErrorCategory.EXECUTION,
                    severity: ErrorSeverity.HIGH,
                    retriable: false,
                    context: { videoId, data },
                });
            }
            // Abortable sleep.
            await new Promise((resolve, reject) => {
                const onAbort = () => {
                    clearTimeout(timer);
                    reject(new AvatarError({
                        code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                        message: `HeyGen poll for video ${videoId} aborted by caller`,
                        category: ErrorCategory.NETWORK,
                        severity: ErrorSeverity.MEDIUM,
                        retriable: false,
                        context: { videoId },
                    }));
                };
                const timer = setTimeout(() => {
                    abortSignal?.removeEventListener("abort", onAbort);
                    resolve();
                }, POLL_INTERVAL_MS);
                abortSignal?.addEventListener("abort", onAbort, { once: true });
            });
        }
        throw new AvatarError({
            code: AVATAR_ERROR_CODES.POLL_TIMEOUT,
            message: `HeyGen video ${videoId} did not complete within ${TOTAL_TIMEOUT_MS / 1000}s`,
            category: ErrorCategory.TIMEOUT,
            severity: ErrorSeverity.MEDIUM,
            retriable: true,
            context: { videoId },
        });
    }
    async download(url) {
        try {
            return await safeDownload(url, {
                maxBytes: MAX_VIDEO_BYTES,
                label: "HeyGen video",
                timeoutMs: REQUEST_TIMEOUT_MS,
            });
        }
        catch (err) {
            throw new AvatarError({
                code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                message: `HeyGen 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();
        let timedOut = false;
        const timeoutId = setTimeout(() => {
            timedOut = true;
            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") {
                // Check caller abort first — a cancelled request is not a timeout.
                if (callerAbortSignal?.aborted) {
                    throw new AvatarError({
                        code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                        message: `HeyGen request to ${url} was aborted by the caller`,
                        category: ErrorCategory.NETWORK,
                        severity: ErrorSeverity.MEDIUM,
                        retriable: false,
                        originalError: err,
                    });
                }
                if (timedOut) {
                    throw new AvatarError({
                        code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                        message: `HeyGen request to ${url} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
                        category: ErrorCategory.NETWORK,
                        severity: ErrorSeverity.HIGH,
                        retriable: true,
                        originalError: err,
                    });
                }
                // Generic abort (shouldn't happen, but surface it).
                throw new AvatarError({
                    code: AVATAR_ERROR_CODES.GENERATION_FAILED,
                    message: `HeyGen request to ${url} was aborted`,
                    category: ErrorCategory.NETWORK,
                    severity: ErrorSeverity.HIGH,
                    retriable: true,
                    originalError: err,
                });
            }
            throw err;
        }
        finally {
            callerAbortSignal?.removeEventListener("abort", onCallerAbort);
            clearTimeout(timeoutId);
        }
    }
}
