/**
 * Device-event notifications — listens to the global events WS and renders
 * snackbar / Web Notification / both per the user's preference matrix.
 *
 * Events handled (dispatched by core/events-ws.ts as `server:<type>`):
 *   - device_health_changed   (online/offline of a configured target)
 *   - device_discovered       (new WLED on LAN / serial port appeared)
 *   - device_lost             (untargeted device disappeared)
 *
 * Anti-spam:
 *   - 10 s startup grace period (no offline toasts during boot — devices
 *     come up at different speeds, the user doesn't need a flurry).
 *   - 5 s flap debounce (state must hold N seconds before notifying).
 *   - bulk coalescing: ≥3 simultaneous events within 800 ms collapse into
 *     one "N devices changed" toast (most likely a network blip, not three
 *     independent failures).
 *
 * The OS channel uses the Web Notifications API (works in any browser tab
 * + the PWA shell, no platform-specific code). Permission is requested
 * lazily — on the first event that wants it, or via the Test button in
 * settings.
 */

import { fetchWithAuth } from '../core/api.ts';
import { showToast } from '../core/ui.ts';
import { t } from '../core/i18n.ts';
import { logError } from '../core/log.ts';

// ─── Types (mirror server/src/ledgrab/api/schemas/preferences.py) ─────

export type NotificationChannel = 'none' | 'snack' | 'os' | 'both';

export interface NotificationChannelMatrix {
    device_online: NotificationChannel;
    device_offline: NotificationChannel;
    device_discovered: NotificationChannel;
    device_lost: NotificationChannel;
}

export interface NotificationPreferences {
    channels: NotificationChannelMatrix;
    background_discovery_enabled: boolean;
    startup_grace_sec: number;
    flap_debounce_sec: number;
}

const DEFAULT_PREFS: NotificationPreferences = {
    channels: {
        device_online: 'snack',
        device_offline: 'both',
        device_discovered: 'snack',
        device_lost: 'none',
    },
    background_discovery_enabled: true,
    startup_grace_sec: 10,
    flap_debounce_sec: 5,
};

// ─── State ────────────────────────────────────────────────────────────

let _prefs: NotificationPreferences = { ...DEFAULT_PREFS };
let _bootedAt = 0;
let _started = false;

/** device-id (or url) -> { lastEventType, deadline ms } pending until debounce expires */
interface PendingState {
    eventType: keyof NotificationChannelMatrix;
    payload: DeviceEventPayload;
    fireAt: number;
    timer: ReturnType<typeof setTimeout>;
}
const _pending = new Map<string, PendingState>();

/** Timestamp window for bulk coalescing. */
let _coalesceTimer: ReturnType<typeof setTimeout> | null = null;
const _coalesceQueue: { eventType: keyof NotificationChannelMatrix; payload: DeviceEventPayload }[] = [];
const COALESCE_WINDOW_MS = 800;
const COALESCE_THRESHOLD = 3;

/** Cached Web Notification permission (mirrors `Notification.permission`). */
let _osPermission: NotificationPermission =
    typeof Notification !== 'undefined' ? Notification.permission : 'denied';

// ─── Public API ───────────────────────────────────────────────────────

export async function startNotificationsWatcher(): Promise<void> {
    if (_started) return;
    _started = true;
    _bootedAt = Date.now();

    await refreshNotificationPreferences().catch((err) =>
        logError('notifications.load_prefs', err),
    );

    // Subscribe to the three event types. The events-ws module dispatches
    // them as `server:<type>` DOM custom events.
    document.addEventListener('server:device_health_changed', _onHealthChanged as EventListener);
    document.addEventListener('server:device_discovered', _onDiscovered as EventListener);
    document.addEventListener('server:device_lost', _onLost as EventListener);
}

/** Pull the latest prefs from the server and cache them. */
export async function refreshNotificationPreferences(): Promise<NotificationPreferences> {
    try {
        const resp = await fetchWithAuth('/preferences/notifications');
        if (!resp.ok) return _prefs;
        const data = await resp.json();
        _prefs = { ...DEFAULT_PREFS, ...data, channels: { ...DEFAULT_PREFS.channels, ...(data.channels || {}) } };
    } catch (err) {
        logError('notifications.fetch', err);
    }
    return _prefs;
}

/** Save prefs to the server and update the cache. Used by the settings UI. */
export async function saveNotificationPreferences(
    next: NotificationPreferences,
): Promise<NotificationPreferences> {
    const resp = await fetchWithAuth('/preferences/notifications', {
        method: 'PUT',
        body: JSON.stringify(next),
    });
    if (!resp.ok) {
        const err = await resp.json().catch(() => ({}));
        throw new Error(err.detail || `HTTP ${resp.status}`);
    }
    const saved = await resp.json();
    _prefs = { ...DEFAULT_PREFS, ...saved, channels: { ...DEFAULT_PREFS.channels, ...(saved.channels || {}) } };
    return _prefs;
}

export function getNotificationPreferences(): NotificationPreferences {
    return _prefs;
}

export function getOsPermission(): NotificationPermission {
    return _osPermission;
}

/**
 * Request OS-toast permission. Returns the new permission state.
 * Lazy — only call from a user gesture, otherwise the prompt is suppressed
 * by browsers that require user interaction (Safari, Firefox).
 */
export async function requestOsNotificationPermission(): Promise<NotificationPermission> {
    if (typeof Notification === 'undefined') return 'denied';
    if (Notification.permission === 'granted' || Notification.permission === 'denied') {
        _osPermission = Notification.permission;
        return _osPermission;
    }
    try {
        _osPermission = await Notification.requestPermission();
    } catch (err) {
        logError('notifications.permission', err);
        _osPermission = 'denied';
    }
    return _osPermission;
}

/** Emit a sample notification through both channels — used by the Test button. */
export function fireTestNotification(): void {
    _render('snack', t('notifications.test.title'), t('notifications.test.body'));
    _render('os', t('notifications.test.title'), t('notifications.test.body'));
}

// ─── Event handlers ──────────────────────────────────────────────────

interface DeviceEventPayload {
    type: string;
    device_id?: string;
    device_type?: string;
    url?: string;
    name?: string;
    online?: boolean;
}

function _onHealthChanged(evt: CustomEvent<DeviceEventPayload>): void {
    const detail = evt.detail || ({} as DeviceEventPayload);
    const eventType: keyof NotificationChannelMatrix = detail.online
        ? 'device_online'
        : 'device_offline';
    _scheduleEvent(eventType, detail, detail.device_id || detail.url || 'unknown');
}

function _onDiscovered(evt: CustomEvent<DeviceEventPayload>): void {
    _scheduleEvent('device_discovered', evt.detail || {}, evt.detail?.url || 'unknown');
}

function _onLost(evt: CustomEvent<DeviceEventPayload>): void {
    _scheduleEvent('device_lost', evt.detail || {}, evt.detail?.url || 'unknown');
}

// ─── Pipeline: grace -> debounce -> coalesce -> render ────────────────

function _scheduleEvent(
    eventType: keyof NotificationChannelMatrix,
    payload: DeviceEventPayload,
    deviceKey: string,
): void {
    const channel = _prefs.channels[eventType];
    if (channel === 'none') return;

    // Startup grace: drop offline events during boot to avoid the
    // "everything offline → online again 200 ms later" flicker some
    // devices show right after the page loads.
    const inGrace = Date.now() - _bootedAt < _prefs.startup_grace_sec * 1000;
    if (inGrace && eventType === 'device_offline') return;

    // Cancel any opposing pending state for this device — if it just went
    // offline-then-online inside the debounce window, neither toast fires.
    const existing = _pending.get(deviceKey);
    if (existing) {
        clearTimeout(existing.timer);
        const opposing =
            (existing.eventType === 'device_online' && eventType === 'device_offline') ||
            (existing.eventType === 'device_offline' && eventType === 'device_online');
        if (opposing) {
            _pending.delete(deviceKey);
            return;
        }
    }

    const debounceMs = _prefs.flap_debounce_sec * 1000;
    const timer = setTimeout(() => {
        _pending.delete(deviceKey);
        _enqueueForCoalesce(eventType, payload);
    }, debounceMs);

    _pending.set(deviceKey, {
        eventType,
        payload,
        fireAt: Date.now() + debounceMs,
        timer,
    });
}

function _enqueueForCoalesce(
    eventType: keyof NotificationChannelMatrix,
    payload: DeviceEventPayload,
): void {
    _coalesceQueue.push({ eventType, payload });
    if (_coalesceTimer) return;
    _coalesceTimer = setTimeout(() => {
        const drained = _coalesceQueue.splice(0);
        _coalesceTimer = null;

        if (drained.length >= COALESCE_THRESHOLD) {
            _renderBulk(drained);
            return;
        }

        for (const item of drained) {
            _renderSingle(item.eventType, item.payload);
        }
    }, COALESCE_WINDOW_MS);
}

function _renderSingle(
    eventType: keyof NotificationChannelMatrix,
    payload: DeviceEventPayload,
): void {
    const channel = _prefs.channels[eventType];
    if (channel === 'none') return;

    const label = payload.name || payload.url || t('notifications.unknown_device');
    const title = t(`notifications.${eventType}.title`);
    const body = t(`notifications.${eventType}.body`).replace('{device}', label);

    _render(channel, title, body);
}

function _renderBulk(
    drained: { eventType: keyof NotificationChannelMatrix; payload: DeviceEventPayload }[],
): void {
    // Use the loudest channel selected across the batch.
    // Order: none < snack < os < both. Picking the first 'both' wins.
    const channelRank: Record<NotificationChannel, number> = {
        none: 0, snack: 1, os: 2, both: 3,
    };
    let loudest: NotificationChannel = 'none';
    for (const item of drained) {
        const ch = _prefs.channels[item.eventType];
        if (channelRank[ch] > channelRank[loudest]) loudest = ch;
        if (loudest === 'both') break;
    }
    if (loudest === 'none') return;

    const title = t('notifications.bulk.title');
    const body = t('notifications.bulk.body').replace('{count}', String(drained.length));
    _render(loudest, title, body);
}

function _render(channel: NotificationChannel, title: string, body: string): void {
    if (channel === 'none') return;

    if (channel === 'snack' || channel === 'both') {
        showToast(`${title}: ${body}`, 'info');
    }
    if (channel === 'os' || channel === 'both') {
        if (typeof Notification === 'undefined') return;
        if (Notification.permission !== 'granted') {
            // Don't auto-prompt mid-event; user grants via the Settings button.
            return;
        }
        try {
            new Notification(title, { body, icon: '/static/icons/icon-192.png', tag: title });
        } catch (err) {
            logError('notifications.render', err);
        }
    }
}
