/**
 * Reusable pairing-flow controller — opens the `#pair-device-modal`,
 * drives its state transitions, POSTs to `/devices/pair`, and resolves
 * with the provider-returned fields so a caller can merge them into a
 * device-create payload.
 *
 * The module is driver-agnostic. Future drivers call
 * `runPairingFlow({deviceType, url})` and consume the returned `fields`
 * directly — the flow knows nothing about Nanoleaf, Tuya, Twinkly, etc.
 *
 * Backend contract:
 *   POST /api/v1/devices/pair  { device_type, url }
 *     200 → { fields: {...} }           — success
 *     409 → device not ready / window timed out  — retryable
 *     400 / 422 / 502 → terminal failure with error detail
 */

import { fetchWithAuth, ApiError } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';

/** Length of the visual pairing window in seconds. Most consumer IoT
 *  devices open a 30s window after the user presses the pairing button;
 *  the ring fills from 0 → full over this period purely as a UI hint —
 *  the actual handshake duration is dictated by the backend, not this
 *  timer. */
const PAIRING_WINDOW_SECONDS = 30;

/** Tiny pause after a successful pair so the user sees the confirmation
 *  before the modal disappears. */
const SUCCESS_DISMISS_DELAY_MS = 450;

export interface PairingFlowOptions {
    /** Device type slug, matched to backend driver registry (e.g. 'nanoleaf'). */
    deviceType: string;
    /** Pairing URL — driver-specific scheme accepted (e.g. 'nanoleaf://10.0.0.5'). */
    url: string;
    /** Override the instructions i18n key. Defaults to
     *  `device.${deviceType}.pair.instructions`, with fallback to
     *  `pairing.instructions.default` when the type-specific key is missing. */
    instructionsKey?: string;
}

export interface PairingFlowResult {
    /** Fields returned by the provider, ready to merge into the
     *  device-create payload (token, client key, group id, …). */
    fields: Record<string, unknown>;
}

/** Sentinel thrown when the user dismisses the modal (cancel / close /
 *  backdrop / Esc). Callers should treat this as a soft cancel — not
 *  an error to surface. */
export class PairingCancelled extends Error {
    constructor() {
        super('Pairing cancelled by user');
        this.name = 'PairingCancelled';
    }
}

type PairState = 'idle' | 'pairing' | 'notready' | 'success' | 'failed';

interface RunCtx {
    modal: PairModal;
    opts: PairingFlowOptions;
    resolve: (value: PairingFlowResult) => void;
    reject: (err: unknown) => void;
    /** AbortController for an in-flight pair request, used by Cancel. */
    abort: AbortController | null;
    /** RequestAnimationFrame/interval handle for the countdown ring. */
    countdownTimer: number | null;
    /** True once we've resolved or rejected — guards double-finalize. */
    settled: boolean;
}

class PairModal extends Modal {
    constructor() { super('pair-device-modal'); }
}

let _modal: PairModal | null = null;
let _ctx: RunCtx | null = null;

/** Lazy single-instance modal so we don't bind listeners until first use. */
function _getModal(): PairModal {
    if (!_modal) _modal = new PairModal();
    return _modal;
}

function _byId<T extends HTMLElement = HTMLElement>(id: string): T | null {
    return document.getElementById(id) as T | null;
}

/** Set the modal's `[data-pair-state]` attribute and toggle child blocks. */
function _setState(state: PairState): void {
    const root = document.querySelector('#pair-device-modal .pair-states') as HTMLElement | null;
    if (!root) return;
    root.setAttribute('data-pair-state', state);
    const blocks = root.querySelectorAll<HTMLElement>('[data-pair-state-block]');
    blocks.forEach(b => {
        const isActive = b.getAttribute('data-pair-state-block') === state;
        b.style.display = isActive ? '' : 'none';
    });
}

/** Apply the type-specific (or fallback) instruction text. */
function _applyInstructions(opts: PairingFlowOptions): void {
    const el = _byId('pair-device-instructions');
    if (!el) return;
    const specificKey = opts.instructionsKey ?? `device.${opts.deviceType}.pair.instructions`;
    const fallbackKey = 'pairing.instructions.default';
    // If the i18n helper returns the key unchanged it means the key is missing —
    // fall back to the generic default so the user always sees real copy.
    const specific = t(specificKey);
    el.textContent = specific === specificKey ? t(fallbackKey) : specific;
    // Keep the data-i18n attribute pointing at whatever key we ended up
    // showing so `updateAllText` (triggered on language change) refreshes it.
    el.setAttribute('data-i18n', specific === specificKey ? fallbackKey : specificKey);
}

/** Start the visual countdown ring. Purely cosmetic — the actual fetch
 *  has its own deadline. */
function _startCountdown(ctx: RunCtx): void {
    const ring = document.querySelector('#pair-device-modal .pair-ring-fg') as SVGCircleElement | null;
    const countEl = _byId('pair-countdown');
    if (!ring || !countEl) return;

    const totalMs = PAIRING_WINDOW_SECONDS * 1000;
    const startedAt = performance.now();

    // pathLength=100, so stroke-dasharray "X 100" fills X% of the circumference.
    ring.style.strokeDasharray = '0 100';
    countEl.textContent = String(PAIRING_WINDOW_SECONDS);

    const tick = () => {
        if (ctx.settled) return;
        const elapsed = performance.now() - startedAt;
        const ratio = Math.min(1, elapsed / totalMs);
        ring.style.strokeDasharray = `${(ratio * 100).toFixed(2)} 100`;
        const remaining = Math.max(0, Math.ceil((totalMs - elapsed) / 1000));
        countEl.textContent = String(remaining);
        if (ratio < 1) {
            ctx.countdownTimer = requestAnimationFrame(tick);
        } else {
            ctx.countdownTimer = null;
        }
    };
    ctx.countdownTimer = requestAnimationFrame(tick);
}

function _stopCountdown(ctx: RunCtx): void {
    if (ctx.countdownTimer !== null) {
        cancelAnimationFrame(ctx.countdownTimer);
        ctx.countdownTimer = null;
    }
}

/** Fire the POST and dispatch to the appropriate post-response state. */
async function _attemptPair(ctx: RunCtx): Promise<void> {
    if (ctx.settled) return;
    _setState('pairing');
    _startCountdown(ctx);

    ctx.abort = new AbortController();
    try {
        const resp = await fetchWithAuth('/devices/pair', {
            method: 'POST',
            body: JSON.stringify({
                device_type: ctx.opts.deviceType,
                url: ctx.opts.url,
            }),
            signal: ctx.abort.signal,
            // The pairing window itself can be ~30s; allow a little headroom.
            timeout: 45_000,
            // Don't retry — pairing isn't idempotent and 5xx during a handshake
            // means the device side is unhappy. Surface immediately.
            retry: false,
        });
        _stopCountdown(ctx);
        ctx.abort = null;
        await _handleResponse(ctx, resp);
    } catch (err: unknown) {
        _stopCountdown(ctx);
        ctx.abort = null;
        if (ctx.settled) return;
        // AbortError surfaces when Cancel was clicked — settled was already
        // set in the cancel handler; nothing more to do.
        if ((err as { name?: string })?.name === 'AbortError') return;
        if (err instanceof ApiError && err.isAuth) {
            // The api layer already opened the login flow; just reject so
            // callers don't hang.
            _finalize(ctx, () => ctx.reject(err));
            return;
        }
        const detail = err instanceof Error ? err.message : t('api.error.network');
        _showFailure(detail);
    }
}

async function _handleResponse(ctx: RunCtx, resp: Response): Promise<void> {
    if (resp.status === 200) {
        let parsed: { fields?: Record<string, unknown> } = {};
        try { parsed = await resp.json() as { fields?: Record<string, unknown> }; }
        catch { /* fall through with empty fields */ }
        const fields = parsed.fields ?? {};
        _setState('success');
        // Brief confirmation before dismissing, so the user gets feedback.
        window.setTimeout(() => {
            _finalize(ctx, () => {
                ctx.modal.forceClose();
                ctx.resolve({ fields });
            });
        }, SUCCESS_DISMISS_DELAY_MS);
        return;
    }

    if (resp.status === 409) {
        _setState('notready');
        return;
    }

    // 400 / 422 / 502 / any other non-2xx → terminal failure.
    let detail = '';
    try {
        const body = await resp.json() as { detail?: string };
        if (typeof body?.detail === 'string') detail = body.detail;
    } catch { /* no JSON body */ }
    _showFailure(detail || `${resp.status} ${resp.statusText}`);
}

function _showFailure(detail: string): void {
    const detailEl = _byId('pair-failure-detail');
    if (detailEl) {
        // Render localized prefix + raw detail. The i18n helper supports
        // {detail} interpolation, so we use the templated form when available
        // and fall back to "Pairing failed: <detail>" assembled manually.
        const templated = t('pairing.failed', { detail });
        // If the helper returned the key unchanged it means the locale file
        // doesn't carry the key — assemble manually as a safety net.
        if (templated === 'pairing.failed' || templated.includes('{detail}')) {
            detailEl.textContent = `${t('pairing.failed_prefix')} ${detail}`.trim();
        } else {
            detailEl.textContent = templated;
        }
    }
    _setState('failed');
}

function _finalize(ctx: RunCtx, work: () => void): void {
    if (ctx.settled) return;
    ctx.settled = true;
    _stopCountdown(ctx);
    if (ctx.abort) { ctx.abort.abort(); ctx.abort = null; }
    _detachHandlers();
    _ctx = null;
    work();
}

// ── Event-handler refs (kept so we can detach on close) ──────────────

let _onStart: (() => void) | null = null;
let _onRetry: (() => void) | null = null;
let _onCloseTerminal: (() => void) | null = null;
let _onCancel: (() => void) | null = null;
let _onCloseBtn: (() => void) | null = null;

function _attachHandlers(ctx: RunCtx): void {
    _onStart = () => { if (!ctx.settled) void _attemptPair(ctx); };
    _onRetry = () => { if (!ctx.settled) void _attemptPair(ctx); };
    const cancelAll = () => {
        _finalize(ctx, () => {
            ctx.modal.forceClose();
            ctx.reject(new PairingCancelled());
        });
    };
    _onCancel = cancelAll;
    _onCloseTerminal = cancelAll;
    _onCloseBtn = cancelAll;

    _byId('pair-start-btn')?.addEventListener('click', _onStart);
    _byId('pair-retry-btn')?.addEventListener('click', _onRetry);
    _byId('pair-close-btn')?.addEventListener('click', _onCloseTerminal);
    _byId('pair-cancel-btn')?.addEventListener('click', _onCancel);
    _byId('pair-device-close-btn')?.addEventListener('click', _onCloseBtn);
}

function _detachHandlers(): void {
    if (_onStart) _byId('pair-start-btn')?.removeEventListener('click', _onStart);
    if (_onRetry) _byId('pair-retry-btn')?.removeEventListener('click', _onRetry);
    if (_onCloseTerminal) _byId('pair-close-btn')?.removeEventListener('click', _onCloseTerminal);
    if (_onCancel) _byId('pair-cancel-btn')?.removeEventListener('click', _onCancel);
    if (_onCloseBtn) _byId('pair-device-close-btn')?.removeEventListener('click', _onCloseBtn);
    _onStart = _onRetry = _onCloseTerminal = _onCancel = _onCloseBtn = null;
}

/**
 * Open the pair modal, run the pairing handshake on user gesture, and
 * resolve with the provider-returned `fields`. Rejects with
 * {@link PairingCancelled} on user dismiss, or with the underlying error
 * for terminal failures.
 *
 * Only one pairing flow can be active at a time — calling this while
 * another is in flight rejects the previous one as cancelled.
 */
export function runPairingFlow(opts: PairingFlowOptions): Promise<PairingFlowResult> {
    // If another flow is mid-run, soft-cancel it. The caller of the
    // previous run will see a PairingCancelled rejection, matching what
    // happens when the user clicks Cancel.
    if (_ctx && !_ctx.settled) {
        const prev = _ctx;
        _finalize(prev, () => {
            prev.modal.forceClose();
            prev.reject(new PairingCancelled());
        });
    }

    return new Promise<PairingFlowResult>((resolve, reject) => {
        const modal = _getModal();
        _applyInstructions(opts);
        _setState('idle');

        const ctx: RunCtx = {
            modal,
            opts,
            resolve,
            reject,
            abort: null,
            countdownTimer: null,
            settled: false,
        };
        _ctx = ctx;
        _attachHandlers(ctx);
        modal.open();
    });
}
