/**
 * @mizan/base — The client state kernel.
 *
 * Zero framework dependencies. React, Vue, Svelte — all import from here.
 *
 * The kernel owns the data. Adapters subscribe and render.
 */

// === Error ===

export class MizanError extends Error {
    public code: string
    public details?: unknown

    constructor(public status: number, public body: string) {
        super(`Mizan call failed (${status})`)
        // Two envelope shapes are tolerated:
        //   FastAPI:  {"error": {"code", "message", "details"}}
        //   Django:   {"error": true, "code", "message", "details"}
        try {
            const parsed = JSON.parse(body)
            const err = parsed?.error
            const source = typeof err === 'object' && err !== null ? err : parsed
            this.code = source?.code ?? `HTTP_${status}`
            this.details = source?.details
            if (source?.message) this.message = source.message
        } catch {
            this.code = `HTTP_${status}`
        }
    }
}

// === Transport ===

/**
 * Wire surface the kernel uses to reach a Mizan backend. The default
 * implementation is `httpTransport()` (POST /call/, GET /ctx/). Tauri
 * apps swap in `tauriTransport()` from `@mizan/tauri-transport`. Any
 * future transport — workers, edge runtimes, channels — implements this
 * interface and replaces the default via `configure({ transport })`.
 */
export interface MizanTransport {
    /** RPC dispatch — invokes a Mizan-registered function. */
    call(
        fnName: string,
        args: Record<string, any>,
    ): Promise<MizanCallResponse>
    /** Context-bundle fetch — invokes a Mizan-registered context. */
    fetch(
        contextName: string,
        params?: Record<string, any>,
    ): Promise<any>
}

/**
 * Raw envelope a transport returns from `call()`. The kernel uses the
 * `merge` and `invalidate` arrays to drive client-side cache updates;
 * `result` is the function's typed return value.
 */
export interface MizanCallResponse {
    result: any
    invalidate?: Array<string | { context: string; params?: Record<string, any> } | { function: string }>
    merge?: Array<{ context: string; slot: string; value: unknown; params?: Record<string, any> }>
}

// === Configuration ===

interface MizanConfig {
    baseUrl: string
    getHeaders: () => Record<string, string> | Promise<Record<string, string>>
    csrfCookieName: string
    csrfHeaderName: string
    /**
     * Whether the backend exposes `/session/` for CSRF/session bootstrap.
     * `true` for Django (the default — preserves existing setups); set
     * `false` for FastAPI or any backend that doesn't ship a session
     * endpoint to avoid a 404 storm on startup. A future revision moves
     * this onto the schema-advertised capability surface.
     */
    session: boolean
    /**
     * Wire transport. Defaults to `httpTransport()` (fetch-based,
     * compatible with FastAPI / Django backends). Swap with a custom
     * transport (e.g. `tauriTransport()`) at app entry to route
     * Mizan calls through a different channel.
     */
    transport: MizanTransport
}

const config: MizanConfig = {
    baseUrl: '/api/mizan',
    getHeaders: () => ({}),
    csrfCookieName: 'csrftoken',
    csrfHeaderName: 'X-CSRFToken',
    session: true,
    // Initialized below once httpTransport is defined.
    transport: null as unknown as MizanTransport,
}

export function configure(opts: Partial<MizanConfig>): void {
    Object.assign(config, opts)
}

export function getConfig(): Readonly<MizanConfig> {
    return config
}

// === CSRF ===

function getCSRFToken(): string | null {
    if (typeof document === 'undefined') return null
    const match = document.cookie.match(new RegExp(`${config.csrfCookieName}=([^;]+)`))
    return match?.[1] ?? null
}

// === Session Init ===

let _sessionReady: Promise<void> | null = null

export function initSession(): Promise<void> {
    if (!config.session) return Promise.resolve()
    if (_sessionReady) return _sessionReady

    _sessionReady = (async () => {
        if (getCSRFToken()) return

        for (let attempt = 0; attempt < 3; attempt++) {
            try {
                await fetch(`${config.baseUrl}/session/`, { credentials: 'include' })
                if (getCSRFToken()) return
            } catch (e) {
                console.warn(`[mizan] Session init attempt ${attempt + 1} failed:`, e)
            }
            if (attempt < 2) await new Promise(r => setTimeout(r, (attempt + 1) * 100))
        }

        _sessionReady = null
    })()

    return _sessionReady
}

// === Context State ===

export type ContextStatus = 'idle' | 'loading' | 'success' | 'error'

export interface ContextState<T = any> {
    data: T | null
    status: ContextStatus
    error: Error | null
}

type Listener = () => void
type ParamKey = string

interface ContextEntry {
    params: Record<string, any>
    state: ContextState
    listeners: Set<Listener>
    fetchFn: () => Promise<any>
}

const contexts: Map<string, Map<ParamKey, ContextEntry>> = new Map()

function stableKey(params: Record<string, any>): string {
    return JSON.stringify(params, Object.keys(params).sort())
}

/**
 * Register a context instance. The kernel owns the fetch lifecycle.
 *
 * Returns { getState, subscribe, refetch, unregister }.
 * Adapters call subscribe() to get notified on state changes.
 */
export function registerContext(
    name: string,
    params: Record<string, any>,
    fetchFn: () => Promise<any>,
    initialData?: any,
): {
    getState: () => ContextState
    subscribe: (listener: Listener) => () => void
    refetch: () => Promise<void>
    unregister: () => void
} {
    if (!contexts.has(name)) contexts.set(name, new Map())
    const key = stableKey(params)
    const map = contexts.get(name)!

    // Reuse existing entry if same key is re-registered (React Strict Mode)
    let entry = map.get(key)
    if (!entry) {
        entry = {
            params,
            state: {
                data: initialData ?? null,
                status: initialData ? 'success' : 'idle',
                error: null,
            },
            listeners: new Set(),
            fetchFn,
        }
        map.set(key, entry)
    } else {
        // Update fetchFn in case closure changed
        entry.fetchFn = fetchFn
    }

    const self = entry

    function notify() {
        self.listeners.forEach(l => l())
    }

    async function refetch() {
        self.state = { ...self.state, status: 'loading', error: null }
        notify()

        try {
            const data = await self.fetchFn()
            self.state = { data, status: 'success', error: null }
        } catch (e) {
            self.state = { ...self.state, status: 'error', error: e as Error }
        }
        notify()
    }

    return {
        getState: () => self.state,
        subscribe: (listener: Listener) => {
            self.listeners.add(listener)
            return () => self.listeners.delete(listener)
        },
        refetch,
        unregister: () => {
            self.listeners.clear()
            map.delete(key)
        },
    }
}

// === Merge ===
//
// A mutation that declares `@client(merge=ctx)` returns `{merge: [{context,
// slot, params?, value}]}` alongside `result`/`invalidate`. The server has
// already resolved which bundle slot the value lands in (by matching the
// mutation's return type against each context function's return type), so
// the kernel does no inference — it writes directly to `bundle[slot]`,
// upserting by id when the slot is a list. The type information lives in
// the schema-aware backend layer; the kernel is type-erased on purpose.

function spliceSlot(slot: unknown, value: unknown): unknown {
    if (Array.isArray(slot)) {
        if (Array.isArray(value)) return value
        if (value && typeof value === 'object' && 'id' in value) {
            const id = (value as { id: unknown }).id
            const idx = slot.findIndex(item =>
                item && typeof item === 'object' && 'id' in item
                    && (item as { id: unknown }).id === id
            )
            const next = slot.slice()
            if (idx >= 0) next[idx] = value
            else next.push(value)
            return next
        }
    }
    return value
}

export function merge(
    context: string,
    params: Record<string, any> | undefined,
    slot: string,
    value: unknown,
): void {
    const entries = contexts.get(context)
    if (!entries) return
    const entry = entries.get(stableKey(params ?? {}))
    if (!entry || entry.state.data == null) return

    const data = entry.state.data
    if (!data || typeof data !== 'object' || Array.isArray(data)) return

    const bundle = data as Record<string, unknown>
    if (!(slot in bundle)) return

    entry.state = {
        data: { ...bundle, [slot]: spliceSlot(bundle[slot], value) },
        status: 'success',
        error: null,
    }
    entry.listeners.forEach(l => l())
}

// === Invalidation ===

const pending: Set<string> = new Set()
const pendingScoped: Array<{ context: string; params: Record<string, any> }> = []
let scheduled = false

export function invalidate(context: string, params?: Record<string, any>): void {
    if (params) {
        pendingScoped.push({ context, params })
    } else {
        pending.add(context)
    }
    if (!scheduled) {
        scheduled = true
        queueMicrotask(flush)
    }
}

function flush(): void {
    // Broad invalidations — refetch all instances
    for (const name of pending) {
        const entries = contexts.get(name)
        if (entries) {
            entries.forEach(entry => {
                entry.state = { ...entry.state, status: 'loading', error: null }
                entry.listeners.forEach(l => l())
                entry.fetchFn().then(data => {
                    entry.state = { data, status: 'success', error: null }
                    entry.listeners.forEach(l => l())
                }).catch(err => {
                    entry.state = { ...entry.state, status: 'error', error: err }
                    entry.listeners.forEach(l => l())
                })
            })
        }
    }

    // Scoped invalidations — refetch matching params
    for (const { context: name, params } of pendingScoped) {
        if (pending.has(name)) continue
        const entries = contexts.get(name)
        if (!entries) continue
        const key = stableKey(params)
        const entry = entries.get(key)
        if (entry) {
            entry.state = { ...entry.state, status: 'loading', error: null }
            entry.listeners.forEach(l => l())
            entry.fetchFn().then(data => {
                entry.state = { data, status: 'success', error: null }
                entry.listeners.forEach(l => l())
            }).catch(err => {
                entry.state = { ...entry.state, status: 'error', error: err }
                entry.listeners.forEach(l => l())
            })
        }
    }

    pending.clear()
    pendingScoped.length = 0
    scheduled = false
}

// === Fetch ===

async function fetchWithRetry(
    input: RequestInfo | URL,
    init?: RequestInit,
    retries = 2,
): Promise<Response> {
    for (let attempt = 0; ; attempt++) {
        try {
            const res = await fetch(input, init)
            if (res.ok || (res.status >= 400 && res.status < 500)) return res
            if (attempt >= retries) return res
        } catch (e) {
            if (attempt >= retries) throw e
        }
        await new Promise(r => setTimeout(r, (attempt + 1) * 200))
    }
}

async function resolveHeaders(): Promise<Record<string, string>> {
    await initSession()

    const custom = await config.getHeaders()
    const csrf = getCSRFToken()

    return {
        ...custom,
        ...(csrf ? { [config.csrfHeaderName]: csrf } : {}),
        'Accept': 'application/json',
    }
}

/**
 * Default Mizan transport — POST `${baseUrl}/call/` and GET
 * `${baseUrl}/ctx/${name}/`. Compatible with `mizan-fastapi`,
 * `mizan-django`, and `mizan-rust-axum`. Swap with a different
 * transport via `configure({ transport })` when running in a
 * non-HTTP host (e.g. Tauri).
 */
export function httpTransport(): MizanTransport {
    return {
        async call(functionName, args) {
            const headers = await resolveHeaders()
            headers['Content-Type'] = 'application/json'

            const res = await fetch(`${config.baseUrl}/call/`, {
                method: 'POST',
                headers,
                credentials: 'same-origin',
                body: JSON.stringify({ fn: functionName, args }),
            })
            if (!res.ok) throw new MizanError(res.status, await res.text())
            return res.json()
        },
        async fetch(contextName, params) {
            const url = new URL(
                `${config.baseUrl}/ctx/${contextName}/`,
                typeof globalThis.location !== 'undefined'
                    ? globalThis.location.origin
                    : 'http://localhost',
            )
            if (params) {
                for (const [k, v] of Object.entries(params)) {
                    url.searchParams.set(k, String(v))
                }
            }
            const headers = await resolveHeaders()
            const res = await fetchWithRetry(url.toString(), {
                headers,
                credentials: 'same-origin',
            })
            if (!res.ok) throw new MizanError(res.status, await res.text())
            return res.json()
        },
    }
}

// Install the default transport now that httpTransport is in scope. The
// config object was constructed earlier with a placeholder so the type
// stayed honest; this line is the actual binding.
config.transport = httpTransport()

export async function mizanFetch(
    contextName: string,
    params?: Record<string, any>,
): Promise<any> {
    return config.transport.fetch(contextName, params)
}

export async function mizanCall(
    functionName: string,
    args: Record<string, any>,
): Promise<any> {
    const data = await config.transport.call(functionName, args)

    // Server-driven merges run before invalidations so a context that is
    // both merged-into and invalidated ends in the invalidation state — the
    // server told us to refetch, that wins.
    if (data.merge) {
        for (const entry of data.merge) {
            merge(entry.context, entry.params, entry.slot, entry.value)
        }
    }

    // Server-driven invalidation
    if (data.invalidate) {
        for (const entry of data.invalidate) {
            if (typeof entry === 'string') {
                invalidate(entry)
            } else if ('context' in entry) {
                invalidate(entry.context, entry.params)
            }
            // {function: name} entries route through the kernel's
            // function-output cache layer, which lives in the framework
            // adapter; mizan-base treats them as a no-op here.
        }
    }

    return data.result
}
