/* Offline-first: sales, loans, repayments and expenses made without internet are kept on the phone (IndexedDB) and
   sent to /sync when the connection comes back. Each item carries its own id, so sending twice saves it once.
   The queue belongs to the signed-in person: another person signing in on the same phone never sends it as theirs. */
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { uuid } from './lib';

export type QueueType = 'sale' | 'loan' | 'repayment' | 'expense';
export type Queued = { client_uuid: string; type: QueueType; at: string; data: Record<string, unknown>; label: string; user: number; error?: string };
type Status = { online: boolean; pending: number; syncing: boolean; failed: Queued[] };

const DB = 'dawn-shop';
const STORE = 'queue';
let userId = 0;
let status: Status = { online: typeof navigator === 'undefined' ? true : navigator.onLine, pending: 0, syncing: false, failed: [] };
const listeners = new Set<(s: Status) => void>();
const emit = (patch: Partial<Status>) => { status = { ...status, ...patch }; listeners.forEach((l) => l(status)); };

function db(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
        const req = indexedDB.open(DB, 1);
        req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'client_uuid' });
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
    });
}
async function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
    const d = await db();
    return new Promise((resolve, reject) => {
        const r = fn(d.transaction(STORE, mode).objectStore(STORE));
        r.onsuccess = () => resolve(r.result);
        r.onerror = () => reject(r.error);
    });
}
const all = async () => (await tx<Queued[]>('readonly', (s) => s.getAll())).filter((q) => q.user === userId);

async function refresh() {
    const items = await all();
    emit({ pending: items.filter((i) => !i.error).length, failed: items.filter((i) => !!i.error) });
}

/** Keep something to send later. Returns its id. */
export async function enqueue(type: QueueType, data: Record<string, unknown>, label: string): Promise<string> {
    const item: Queued = { client_uuid: (data.client_uuid as string) || uuid(), type, at: new Date().toISOString(), data, label, user: userId };
    await tx('readwrite', (s) => s.put(item));
    await refresh();
    return item.client_uuid;
}

export async function discard(id: string) {
    await tx('readwrite', (s) => s.delete(id));
    await refresh();
}

const xsrf = () => decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '');

/** Send everything waiting. Items the server refuses (e.g. a product removed meanwhile) are kept with the reason. */
export async function flush(): Promise<void> {
    if (status.syncing || !navigator.onLine) return;
    const items = (await all()).filter((i) => !i.error);
    if (!items.length) return;
    emit({ syncing: true });
    try {
        // a fresh session cookie first, in case the phone was offline long enough for it to change
        await fetch(route('sync.ping'), { credentials: 'same-origin', headers: { Accept: 'application/json' } });
        const send = () => fetch(route('sync.store'), {
            method: 'POST', credentials: 'same-origin',
            headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-XSRF-TOKEN': xsrf(), 'X-Requested-With': 'XMLHttpRequest' },
            body: JSON.stringify({ items: items.map(({ client_uuid, type, at, data }) => ({ client_uuid, type, at, data })) }),
        });
        let res = await send();
        // 419: the session was renewed meanwhile; the reply set a fresh token, so once more
        if (res.status === 419) res = await send();
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const { results } = (await res.json()) as { results: { client_uuid: string; ok: boolean; error?: string }[] };
        for (const r of results) {
            if (r.ok) await tx('readwrite', (s) => s.delete(r.client_uuid));
            else {
                const item = items.find((i) => i.client_uuid === r.client_uuid);
                if (item) await tx('readwrite', (s) => s.put({ ...item, error: r.error ?? 'Not accepted' }));
            }
        }
        // show the new totals and stock
        router.reload();
    } catch {
        // still no connection (or the server is busy): try again later
    } finally {
        emit({ syncing: false });
        await refresh();
    }
}

/** Called once the signed-in person is known. */
export function startOffline(user: number) {
    if (userId === user) return;
    userId = user;
    refresh();
    flush();
}

if (typeof window !== 'undefined') {
    window.addEventListener('online', () => { emit({ online: true }); flush(); });
    window.addEventListener('offline', () => emit({ online: false }));
    setInterval(() => { if (status.pending && navigator.onLine) flush(); }, 30000);
}

export function useOffline(): Status {
    const [s, set] = useState(status);
    useEffect(() => { listeners.add(set); set(status); return () => { listeners.delete(set); }; }, []);
    return s;
}

/**
 * Post to the server, or keep it on the phone when there is no connection (or it drops during the request).
 * onQueued runs instead of onSuccess when it was kept for later.
 */
export function submitOrQueue(opts: {
    url: string; type: QueueType; data: Record<string, unknown>; label: string;
    onSuccess?: () => void; onQueued?: () => void; onError?: (errors: Record<string, string>) => void; onFinish?: () => void;
}) {
    const queue = async () => { await enqueue(opts.type, opts.data, opts.label); opts.onQueued?.(); opts.onFinish?.(); };
    if (!navigator.onLine) { queue(); return; }
    let queued = false;
    const off = router.on('exception', (event) => {
        // no answer at all from the server: the connection dropped
        const exception = (event as CustomEvent).detail?.exception as { response?: unknown } | undefined;
        if (exception && !exception.response) { event.preventDefault(); queued = true; queue(); }
    });
    router.post(opts.url, opts.data as never, {
        preserveScroll: true, preserveState: true,
        onSuccess: () => opts.onSuccess?.(),
        onError: (e) => opts.onError?.(e),
        onFinish: () => { off(); if (!queued) opts.onFinish?.(); },
    });
}

/** Signing out with things not yet sent: they would wait for this person to sign in again on this phone. */
export const pendingCount = () => status.pending;

/** Forget what the browser kept for offline use (on sign-out, so the next person can't see it). */
export async function clearOfflineCache() {
    if ('caches' in window) for (const k of await caches.keys()) await caches.delete(k);
}
