import { apiFetch } from "../../net/api";
import { deliverIncoming, openIncomingSink } from "./incomingSink";
import type { FileSource } from "./source";

// Relay path (PROJECT_BRIEF.md §M5/M9). Sender uploads sequential 1 MiB chunks
// via POST and receiver streams the response via GET. Streaming POST is NOT
// used because Safari/Firefox don't support ReadableStream upload.

const CHUNK = 1024 * 1024;

export interface RelayProgress
{
    bytes: number;
    total: number;
}

export type RelayHandle = {
    cancel: () => void;
};

const MAX_CHUNK_RETRIES = 3;
const RETRY_BACKOFF_MS = [500, 1500, 4500];
// 单 chunk 最长允许多久。1 MiB chunk 在 relay 通畅时几百 ms；这里给够 60s
// 余量并兜底"receiver 没在读 → ring buffer 写满 → POST 永远不返回"的死锁。
const PER_CHUNK_TIMEOUT_MS = 60_000;

// relaySendFile streams a file as 1 MiB POSTs. Each chunk is retried up to
// MAX_CHUNK_RETRIES times on transient errors. A 409 response means the
// server already has bytes past our offset — typically we already sent
// successfully but the response was lost; resume from server's
// X-Bytes-Received and don't re-send the duplicate.
export async function relaySendFile(
    sessionId: string,
    src: FileSource,
    onProgress?: (p: RelayProgress) => void,
    abortSignal?: AbortSignal,
): Promise<void>
{
    const url = `/api/relay/${encodeURIComponent(sessionId)}/chunk`;
    let offset = 0;
    while (offset < src.size)
    {
        if (abortSignal?.aborted) { throw new Error("relay send aborted"); }

        const end = Math.min(offset + CHUNK, src.size);
        // iOS 下经 Range 向原生拉该 1 MiB 片，整文件不进 WebView 内存（R-iOS-4）。
        const slice = await src.slice(offset, end);
        const isLast = end === src.size;

        const result = await postChunkWithRetry(url, slice, offset, isLast, abortSignal);
        if (result.skip)
        {
            // Server says it already has at least up to `serverOffset` bytes.
            offset = Math.max(end, result.serverOffset);
        }
        else
        {
            offset = end;
        }
        onProgress?.({ bytes: offset, total: src.size });
    }
}

interface ChunkResult
{
    skip: boolean;
    serverOffset: number;
}

interface ScopedSignal
{
    signal: AbortSignal;
    cancel: () => void;
}

// withDeadline 把外层 abort（cleanup / 用户取消）与 N ms 超时合成一个新的
// AbortSignal。AbortSignal.any() 在所有目标浏览器（Chrome 116 / Firefox 124 /
// Safari 17.4）已可用，但仍写一个手动 fallback 防 polyfill 缺失。
function withDeadline(outer: AbortSignal | undefined, ms: number): ScopedSignal
{
    const ctrl = new AbortController();
    const timeoutId = window.setTimeout(
        () => { ctrl.abort(new DOMException("relay chunk timeout", "TimeoutError")); },
        ms,
    );
    let off = () => {};
    if (outer)
    {
        if (outer.aborted) { ctrl.abort(outer.reason); }
        else
        {
            const onAbort = () => { ctrl.abort(outer.reason); };
            outer.addEventListener("abort", onAbort, { once: true });
            off = () => { outer.removeEventListener("abort", onAbort); };
        }
    }
    return {
        signal: ctrl.signal,
        cancel: () =>
        {
            window.clearTimeout(timeoutId);
            off();
        },
    };
}

async function postChunkWithRetry(
    url: string,
    slice: Uint8Array<ArrayBuffer>,
    offset: number,
    isLast: boolean,
    abortSignal?: AbortSignal,
): Promise<ChunkResult>
{
    let lastErr: unknown = null;
    for (let attempt = 0; attempt < MAX_CHUNK_RETRIES; attempt += 1)
    {
        if (abortSignal?.aborted) { throw new Error("relay send aborted"); }

        const headers: Record<string, string> = {
            "Content-Type": "application/octet-stream",
            "X-Resume-Offset": String(offset),
        };
        if (isLast) { headers["X-End"] = "true"; }

        const scoped = withDeadline(abortSignal, PER_CHUNK_TIMEOUT_MS);
        try
        {
            const r = await apiFetch(url, {
                method: "POST",
                headers,
                body: slice,
                signal: scoped.signal,
            });

            if (r.ok)
            {
                const recv = Number(r.headers.get("X-Bytes-Received") ?? "0");
                return { skip: false, serverOffset: recv };
            }
            if (r.status === 409)
            {
                // Server's expected offset > ours: previous chunk landed but
                // the response was lost. Skip this duplicate.
                const recv = Number(r.headers.get("X-Bytes-Received") ?? "0");
                if (recv > offset) { return { skip: true, serverOffset: recv }; }
                throw new Error(`relay 409 with recv=${recv} <= offset=${offset}`);
            }
            const text = await r.text().catch(() => r.statusText);
            throw new Error(`relay chunk ${r.status}: ${text}`);
        }
        catch (e)
        {
            lastErr = e;
            if (attempt < MAX_CHUNK_RETRIES - 1)
            {
                await new Promise((res) => setTimeout(res, RETRY_BACKOFF_MS[attempt] ?? 5000));
            }
        }
        finally
        {
            scoped.cancel();
        }
    }
    throw lastErr instanceof Error ? lastErr : new Error("relay send retries exhausted");
}

export async function relayReceiveFile(
    sessionId: string,
    fileName: string,
    expectedSize: number,
    onProgress?: (p: RelayProgress) => void,
    abortSignal?: AbortSignal,
): Promise<void>
{
    const url = `/api/relay/${encodeURIComponent(sessionId)}/stream`;
    const r = await apiFetch(url, { method: "GET", signal: abortSignal });
    if (!r.ok)
    {
        const text = await r.text().catch(() => r.statusText);
        throw new Error(`relay stream ${r.status}: ${text}`);
    }
    if (!r.body) { throw new Error("relay: no response body"); }

    const reader = r.body.getReader();
    const sink = await openIncomingSink(sessionId, fileName);
    let received = 0;
    try
    {
        while (true)
        {
            if (abortSignal?.aborted) { throw new Error("relay receive aborted"); }
            const { value, done } = await reader.read();
            if (done) { break; }
            if (!value) { continue; }
            // 与 P2P 路径一致流式落 OPFS / 内存：避免大文件在 iOS 撑爆 JS 堆
            await sink.write(value.slice().buffer);
            received += value.byteLength;
            onProgress?.({ bytes: received, total: expectedSize });
        }
    }
    catch (e)
    {
        await sink.cancel();
        throw e;
    }
    finally
    {
        try { reader.cancel(); } catch { /* noop */ }
    }

    const result = await sink.close();
    deliverIncoming(result, fileName, sessionId);
}
