// Director panel — Lumi cartoon, mood word, note, KL-64 personality cues
// (beat pulse, superbeat flash, thinking dots, cycle countdown, mood-color
// glow). Extracted from show.js per KL-109. main.js calls these on the
// 10Hz state push.

import {pricingUrlWithContext} from '../js/utils.js';


let lastMoodText = null;
let lastCartoonKey = null;

// Emotion → cartoon-asset entry. The director picks one of these
// values as a post-hoc visual tag alongside its other output; the
// backend validator coerces anything off-list to 'default' so this
// map is the only authority on which file to render. Keep keys 1:1
// with conductor/director/base.py's LUMI_EMOTIONS tuple.
// KL-100: the new batch of Lumi assets is uniformly waist-up, so the
// per-entry `framing` field is no longer needed. The CSS rules that
// keyed on `[data-framing="bust"]` were removed alongside this; both
// the .director-cartoon img and video elements now render at the
// single waist-up width.
const EMOTION_IMAGES = {
    buildup:       { file: 'lumi-buildup.png',       video: 'lumi-buildup.mp4',       color: '#ffaa44' },
    chill:         { file: 'lumi-chill.png',         video: 'lumi-chill.mp4',         color: '#7cc8ff' },
    contemplative: { file: 'lumi-contemplative.png', video: 'lumi-contemplative.mp4', color: '#b9a3e8' },
    dark:          { file: 'lumi-dark.png',          video: 'lumi-dark.mp4',          color: '#5050a0' },
    default:       { file: 'lumi-default.png',       video: 'lumi-default.mp4',       color: '#b48cff' },
    defiant:       { file: 'lumi-defiant.png',       video: 'lumi-defiant.mp4',       color: '#ff6644' },
    ecstatic:      { file: 'lumi-ecstatic.png',      video: 'lumi-ecstatic.mp4',      color: '#ff5cc8' },
    focused:       { file: 'lumi-focused.png',       video: 'lumi-focused.mp4',       color: '#00d4cc' },
    glitchy:       { file: 'lumi-glitchy.png',       video: 'lumi-glitchy.mp4',       color: '#7cff44' },
    groove:        { file: 'lumi-groove.png',        video: 'lumi-groove.mp4',        color: '#ff7ab8' },
    hype:          { file: 'lumi-hype.png',          video: 'lumi-hype.mp4',          color: '#ff5566' },
    hypnotic:      { file: 'lumi-hypnotic.png',      video: 'lumi-hypnotic.mp4',      color: '#7050ff' },
    joyful:        { file: 'lumi-joyful.png',        video: 'lumi-joyful.mp4',        color: '#ffcc44' },
    mysterious:    { file: 'lumi-mysterious.png',    video: 'lumi-mysterious.mp4',    color: '#3da095' },
    nostalgic:     { file: 'lumi-nostalgic.png',     video: 'lumi-nostalgic.mp4',     color: '#ec92a8' },
    playful:       { file: 'lumi-playful.png',       video: 'lumi-playful.mp4',       color: '#ffea44' },
    protective:    { file: 'lumi-protective.png',    video: 'lumi-protective.mp4',    color: '#ffa040' },
    surgical:      { file: 'lumi-surgical.png',      video: 'lumi-surgical.mp4',      color: '#5099cc' },
    triumphant:    { file: 'lumi-triumphant.png',    video: 'lumi-triumphant.mp4',    color: '#ffd44a' },
    unhinged:      { file: 'lumi-unhinged.png',      video: 'lumi-unhinged.mp4',      color: '#a8ff44' },
    void:          { file: 'lumi-void.png',          video: 'lumi-void.mp4',          color: '#7048a8' },
    warning:       { file: 'lumi-warning.png',       video: 'lumi-warning.mp4',       color: '#ff7038' },
    wild:          { file: 'lumi-wild.png',          video: 'lumi-wild.mp4',          color: '#ff44a8' },
};
// KL-209: point the fallback at the canonical `default` entry so it
// inherits the `video:` field. Without this, an initial state push
// with null/empty emotion would render only the static PNG (no loop)
// until the director emitted its first real emotion.
const CARTOON_DEFAULT_ENTRY = EMOTION_IMAGES.default;
const CARTOON_BASE = '/static/images/director/';

// Defensive 404 cache — silently fall back to default if a deploy ever
// lands the JS map ahead of an asset.
const _missingCartoons = new Set();

// KL-194: two-video crossfade state. activeVideoIdx is which of the two
// <video> elements is currently fully opaque (the .is-active one).
// On emotion change setCartoon loads the new src into the inactive
// element, waits for canplay, then swaps the .is-active class so the
// CSS opacity transition crossfades them. _pendingFade tracks any
// in-flight transition so a rapid second emotion-change can cancel
// it cleanly instead of piling up promises.
let activeVideoIdx = 0;
let _pendingFade = null;
const FADE_MS = 400;          // matches CSS transition duration
const CANPLAY_TIMEOUT_MS = 1500;  // hard fallback if canplay never fires

function _cancelPendingFade() {
    if (!_pendingFade) return;
    if (_pendingFade.canplayCleanup) _pendingFade.canplayCleanup();
    if (_pendingFade.canplayTimer) clearTimeout(_pendingFade.canplayTimer);
    if (_pendingFade.postFadeTimer) clearTimeout(_pendingFade.postFadeTimer);
    if (_pendingFade.playingWatchdog) clearTimeout(_pendingFade.playingWatchdog);
    if (_pendingFade.playingCleanup) _pendingFade.playingCleanup();
    _pendingFade = null;
}

function setCartoon(imgEl, emotion) {
    if (!imgEl) return;
    const videoA = document.getElementById('director-cartoon-video');
    const videoB = document.getElementById('director-cartoon-video-b');
    const key = (emotion || '').toLowerCase();
    const entry = EMOTION_IMAGES[key] || CARTOON_DEFAULT_ENTRY;
    if (entry.file === lastCartoonKey) return;
    lastCartoonKey = entry.file;

    const posterUrl = CARTOON_BASE + entry.file;
    const fallbackUrl = CARTOON_BASE + CARTOON_DEFAULT_ENTRY.file;
    const useVideo = !!entry.video && !!videoA && !!videoB;

    // Cancel any in-flight crossfade so rapid emotion swaps don't
    // pile up canplay listeners or post-fade cleanups against stale
    // video elements.
    _cancelPendingFade();

    if (useVideo) {
        const videoUrl = CARTOON_BASE + entry.video;
        const incoming = activeVideoIdx === 0 ? videoB : videoA;
        const outgoing = activeVideoIdx === 0 ? videoA : videoB;

        incoming.poster = posterUrl;
        if (incoming.src !== videoUrl) incoming.src = videoUrl;

        // Triggered once incoming has a frame ready (or the safety
        // timeout fires). Swaps .is-active to drive the opacity
        // crossfade, then schedules a post-fade cleanup that frees
        // the outgoing video's decoder.
        let fired = false;
        const triggerFade = () => {
            if (fired) return;
            fired = true;
            outgoing.classList.remove('is-active');
            incoming.classList.add('is-active');
            imgEl.hidden = true;  // image-only fallback never visible in video mode
            const p = incoming.play();
            if (p && typeof p.catch === 'function') p.catch(() => {});

            // KL-206: playing-event watchdog. WKWebView occasionally
            // resolves play() without actually starting playback (most
            // often when the app loses focus to a screen recorder or
            // another app between canplay and the play call). If the
            // `playing` event doesn't fire within 700ms, retry play()
            // once — that's enough to recover from the silent-drop case.
            let playingFired = false;
            const onPlaying = () => { playingFired = true; };
            incoming.addEventListener('playing', onPlaying, { once: true });
            const playingWatchdog = setTimeout(() => {
                if (playingFired) return;
                incoming.removeEventListener('playing', onPlaying);
                try {
                    const retry = incoming.play();
                    if (retry && typeof retry.catch === 'function') retry.catch(() => {});
                } catch (_) { /* no-op */ }
            }, 700);
            if (_pendingFade) {
                _pendingFade.playingWatchdog = playingWatchdog;
                _pendingFade.playingCleanup = () => incoming.removeEventListener('playing', onPlaying);
            }

            // Free the now-faded-out video's decode resources after
            // the transition settles. Buffer past FADE_MS so we don't
            // pause mid-animation.
            const postFadeTimer = setTimeout(() => {
                if (_pendingFade && _pendingFade.postFadeTimer === postFadeTimer) {
                    _pendingFade = null;
                }
                try {
                    outgoing.pause();
                    outgoing.removeAttribute('src');
                    outgoing.load();
                } catch (_) { /* no-op */ }
            }, FADE_MS + 100);
            if (_pendingFade) _pendingFade.postFadeTimer = postFadeTimer;
        };

        const onCanplay = () => triggerFade();
        incoming.addEventListener('canplay', onCanplay, { once: true });
        const canplayTimer = setTimeout(() => triggerFade(), CANPLAY_TIMEOUT_MS);
        _pendingFade = {
            canplayCleanup: () => incoming.removeEventListener('canplay', onCanplay),
            canplayTimer,
            postFadeTimer: null,
        };

        activeVideoIdx = activeVideoIdx === 0 ? 1 : 0;
        return;
    }

    // Image-only emotion: fade both videos out, restore the image.
    // Image-only path is rare in practice (KL-100 ships MP4 for every
    // emotion); keeping it intact for the defensive 404-fallback case.
    if (videoA) videoA.classList.remove('is-active');
    if (videoB) videoB.classList.remove('is-active');
    setTimeout(() => {
        if (videoA) { try { videoA.pause(); videoA.removeAttribute('src'); videoA.load(); } catch (_) {} }
        if (videoB) { try { videoB.pause(); videoB.removeAttribute('src'); videoB.load(); } catch (_) {} }
    }, FADE_MS + 100);
    imgEl.hidden = false;
    if (_missingCartoons.has(posterUrl)) {
        imgEl.src = fallbackUrl;
        return;
    }
    imgEl.src = posterUrl;
    imgEl.onerror = () => {
        _missingCartoons.add(posterUrl);
        imgEl.onerror = null;
        imgEl.src = fallbackUrl;
    };
}

// Tier the mood font-size by character count (KL-46). Whitespace doesn't
// count toward length — "BUILD UP" reads as 7 chars, not 8.
function fitMoodText(el) {
    if (!el) return;
    const chars = (el.textContent || '').replace(/\s+/g, '').length;
    if (chars <= 6) {
        el.style.fontSize = '';
    } else if (chars <= 10) {
        el.style.fontSize = '4rem';
    } else {
        el.style.fontSize = '2.8rem';
    }
}

// KL-68: distinct off-state copy.
let _lastNoteShape = null;
let _lastNoteContent = null;

function _isTierLocked() {
    const tier = (window.CONDUCTOR_TIER || 'starter').toLowerCase();
    return tier === 'starter';
}

function _renderTierLockedNote(noteEl) {
    if (!noteEl) return;
    noteEl.innerHTML = '';
    noteEl.append("Lumi's an AI Director — ");
    const link = document.createElement('a');
    link.className = 'director-note-link';
    link.href = pricingUrlWithContext('director_panel');
    link.target = '_blank';
    link.rel = 'noopener noreferrer';
    link.textContent = 'Get Pro';
    link.addEventListener('click', () => {
        try {
            import('./utils.js').then(({trackEvent}) => {
                const props = {surface: 'director_panel', tier_required: 'pro'};
                trackEvent('upgrade.cta_clicked', {...props, action: 'get_pro'});
                trackEvent('checkout.opened', props);
            }).catch(() => {});
        } catch (_) {}
    });
    noteEl.appendChild(link);
    noteEl.append(' to wake her up.');
}

function _renderDisabledNote(noteEl) {
    if (!noteEl) return;
    noteEl.innerHTML = '';
    noteEl.append('Wake Lumi in the ');
    const link = document.createElement('a');
    link.className = 'director-note-link';
    link.href = '#';
    link.textContent = 'Director tab';
    link.addEventListener('click', (e) => {
        e.preventDefault();
        document.getElementById('tab-btn-director')?.click();
    });
    noteEl.appendChild(link);
    noteEl.append('.');
}

export function updateDirector(director) {
    if (!director) return;
    const moodEl = document.getElementById('director-mood');
    const noteEl = document.getElementById('director-note');
    const backendEl = document.getElementById('director-backend');
    const statusDirEl = document.getElementById('director-status');
    const cartoonEl = document.getElementById('director-cartoon');

    const tierLocked = _isTierLocked();
    const settingsOff = !director.enabled && !tierLocked;
    const offState = tierLocked ? 'tier_locked' : (settingsOff ? 'disabled' : null);

    if (moodEl) {
        let newMood;
        if (offState === 'tier_locked') {
            newMood = 'ASLEEP';
        } else if (offState === 'disabled') {
            newMood = 'DISABLED';
        } else {
            newMood = director.mood || 'READY';
        }
        if (newMood !== lastMoodText) {
            moodEl.textContent = newMood;
            lastMoodText = newMood;
        }
        fitMoodText(moodEl);
        if (offState) {
            moodEl.style.color = '';
        } else {
            const moodColor = director.params && director.params.mood_color;
            moodEl.style.color = moodColor || '';
        }
    }

    setCartoon(cartoonEl, director.emotion);

    if (noteEl) {
        const newShape = offState || 'active';
        const newText = (offState === null) ? (director.note || 'Waiting for first direction...') : null;
        const shapeChanged = newShape !== _lastNoteShape;
        const textChanged = newText !== _lastNoteContent;
        if (shapeChanged || (offState === null && textChanged)) {
            if (offState === 'tier_locked') {
                _renderTierLockedNote(noteEl);
            } else if (offState === 'disabled') {
                _renderDisabledNote(noteEl);
            } else {
                noteEl.textContent = newText;
            }
            _lastNoteShape = newShape;
            _lastNoteContent = newText;
        }
    }

    if (backendEl) backendEl.textContent = director.backend || '--';
    if (statusDirEl) {
        if (offState === 'tier_locked') {
            statusDirEl.textContent = 'Locked';
        } else {
            statusDirEl.textContent = director.enabled ? 'Active' : 'Disabled';
        }
    }

    const panel = document.querySelector('.director-panel');
    if (panel) {
        if (offState) {
            panel.dataset.directorState = offState;
        } else {
            delete panel.dataset.directorState;
        }
    }
}

// ─── KL-64 personality cues ────────────────────────────────────────

let _lastBeatMax = 0;
let _lastBeatPulseTs = 0;
const BEAT_PULSE_RISE = 0.18;
const BEAT_PULSE_FLOOR = 0.30;
const BEAT_PULSE_MIN_GAP_MS = 110;
const BEAT_PULSE_HOLD_MS = 150;

export function updateBeatPulse(beatEnergy) {
    const el = document.getElementById('director-beat-pulse');
    if (!el || !Array.isArray(beatEnergy)) return;
    const max = Math.max(beatEnergy[0] || 0, beatEnergy[1] || 0, beatEnergy[2] || 0);
    const now = performance.now();
    if (
        max > _lastBeatMax + BEAT_PULSE_RISE &&
        max > BEAT_PULSE_FLOOR &&
        now - _lastBeatPulseTs > BEAT_PULSE_MIN_GAP_MS
    ) {
        el.classList.remove('beat-pulse-active');
        void el.offsetWidth;
        el.classList.add('beat-pulse-active');
        _lastBeatPulseTs = now;
        setTimeout(() => el.classList.remove('beat-pulse-active'), BEAT_PULSE_HOLD_MS);
    }
    _lastBeatMax = max;
}

let _lastSuperbeat = false;
const SUPERBEAT_FLASH_MS = 350;

export function updateSuperbeat(superbeatActive) {
    if (!!superbeatActive && !_lastSuperbeat) {
        const el = document.getElementById('director-superbeat-flash');
        if (el) {
            el.classList.remove('superbeat-flash-active');
            void el.offsetWidth;
            el.classList.add('superbeat-flash-active');
            setTimeout(
                () => el.classList.remove('superbeat-flash-active'),
                SUPERBEAT_FLASH_MS,
            );
        }
    }
    _lastSuperbeat = !!superbeatActive;
}

export function updateThinking(pending) {
    const meta = document.querySelector('.director-meta');
    if (!meta) return;
    meta.classList.toggle('thinking', !!pending);
}

const CYCLE_DURATION_S = 15;
let _lastCycleTs = 0;

export function updateCycleBar(lastDecisionTs) {
    const fill = document.getElementById('director-cycle-fill');
    if (!fill) return;
    if (!lastDecisionTs) {
        fill.style.width = '0%';
        return;
    }
    if (lastDecisionTs !== _lastCycleTs) {
        fill.style.transition = 'none';
        fill.style.width = '0%';
        void fill.offsetWidth;
        fill.style.transition = '';
        _lastCycleTs = lastDecisionTs;
        return;
    }
    const elapsed = (Date.now() / 1000) - lastDecisionTs;
    const pct = Math.max(0, Math.min(100, (elapsed / CYCLE_DURATION_S) * 100));
    fill.style.width = `${pct}%`;
}

let _lastPulseColor = null;

export function updatePanelPulse(emotion, moodColor) {
    const panel = document.querySelector('.director-panel');
    if (!panel) return;
    const entry = EMOTION_IMAGES[(emotion || '').toLowerCase()] || CARTOON_DEFAULT_ENTRY;
    const color = (moodColor && moodColor.trim()) || entry.color || '#b48cff';
    if (color === _lastPulseColor) return;
    _lastPulseColor = color;
    panel.style.setProperty('--lumi-pulse-color', _hexToRgba(color, 0.28));
    panel.style.setProperty('--lumi-border-color', _hexToRgba(color, 0.85));
}

// KL-206: when the page returns to visible (e.g. user Cmd+Tabs back from
// a screen recorder), nudge whichever video is currently .is-active to
// resume. play() on an already-playing element is a no-op; play() on a
// silently-dropped element wakes it back up. This covers the common
// "I was recording the app and the videos froze on poster frames" case.
if (typeof document !== 'undefined') {
    document.addEventListener('visibilitychange', () => {
        if (document.hidden) return;
        const a = document.getElementById('director-cartoon-video');
        const b = document.getElementById('director-cartoon-video-b');
        const active = a && a.classList.contains('is-active')
            ? a
            : (b && b.classList.contains('is-active') ? b : null);
        if (!active || !active.src) return;
        try {
            const p = active.play();
            if (p && typeof p.catch === 'function') p.catch(() => {});
        } catch (_) { /* no-op */ }
    });
}

function _hexToRgba(input, alpha) {
    if (!input) return `rgba(180, 140, 255, ${alpha})`;
    const s = input.trim();
    if (s.startsWith('rgb')) return s;
    let hex = s.replace('#', '');
    if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
    if (hex.length !== 6) return `rgba(180, 140, 255, ${alpha})`;
    const r = parseInt(hex.slice(0, 2), 16);
    const g = parseInt(hex.slice(2, 4), 16);
    const b = parseInt(hex.slice(4, 6), 16);
    return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
