const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
const DEFAULT_FETCH_TIMEOUT_MS = 20_000;
const MAX_FETCH_TIMEOUT_MS = 300_000;
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 400;
const BOOTSTRAP_TOKEN_PATH = "/mjr/am/settings/security/bootstrap-token";
const API_PREFIX = "/mjr/am/";
const _pendingRequests = new Map();

export function delay(ms: number) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

function _methodIsWrite(method: any) {
    return WRITE_METHODS.has(
        String(method || "")
            .trim()
            .toUpperCase(),
    );
}

function _normalizeUrlPath(url: any) {
    try {
        const raw = String(url || "").trim();
        if (!raw) return "";
        if (raw.startsWith("http://") || raw.startsWith("https://")) {
            const base =
                typeof globalThis !== "undefined" && globalThis?.location?.origin
                    ? String(globalThis.location.origin)
                    : "http://localhost";
            return new URL(raw, base).pathname || "";
        }
        return raw.split("?")[0] || "";
    } catch {
        return "";
    }
}

function _isMajoorApiUrl(url: any) {
    return _normalizeUrlPath(url).startsWith(API_PREFIX);
}

function _isBootstrapTokenUrl(url: any) {
    return _normalizeUrlPath(url) === BOOTSTRAP_TOKEN_PATH;
}

function _isRetryableError(error: any) {
    try {
        if (!error) return false;
        const name = String(error.name || "");
        if (name === "AbortError") return false;
        const msg = String(error.message || "").toLowerCase();
        if (name === "TypeError") {
            return (
                msg.includes("failed to fetch") ||
                msg.includes("networkerror") ||
                msg.includes("load failed") ||
                msg.includes("fetch") ||
                msg.includes("network")
            );
        }
        return msg.includes("fetch") || msg.includes("network") || msg.includes("failed");
    } catch {
        return false;
    }
}

function _resolveFetchTimeoutMs(options: Record<string, any> = {}) {
    try {
        const raw = Number(options?.timeoutMs);
        if (!Number.isFinite(raw)) return DEFAULT_FETCH_TIMEOUT_MS;
        return Math.max(1_000, Math.min(MAX_FETCH_TIMEOUT_MS, Math.floor(raw)));
    } catch {
        return DEFAULT_FETCH_TIMEOUT_MS;
    }
}

function _buildTimedSignal(options: Record<string, any> = {}) {
    const upstreamSignal = options?.signal || null;
    if (typeof AbortController === "undefined") {
        return {
            signal: upstreamSignal || undefined,
            timeoutMs: _resolveFetchTimeoutMs(options),
            cleanup: () => {},
        };
    }
    const timeoutMs = _resolveFetchTimeoutMs(options);
    const ctrl = new AbortController();
    let timer: ReturnType<typeof setTimeout> | null = null;
    const onAbort = () => {
        try {
            if (timer) {
                clearTimeout(timer);
                timer = null;
            }
        } catch (e) {
            console.debug?.(e);
        }
        try {
            ctrl.abort();
        } catch (e) {
            console.debug?.(e);
        }
    };
    try {
        timer = setTimeout(() => {
            try {
                ctrl.abort();
            } catch (e) {
                console.debug?.(e);
            }
        }, timeoutMs);
    } catch (e) {
        console.debug?.(e);
    }
    try {
        if (upstreamSignal) {
            if (upstreamSignal.aborted) {
                onAbort();
            } else {
                upstreamSignal.addEventListener("abort", onAbort, { once: true });
            }
        }
    } catch (e) {
        console.debug?.(e);
    }
    return {
        signal: ctrl.signal,
        timeoutMs,
        cleanup: () => {
            try {
                if (timer) clearTimeout(timer);
            } catch (e) {
                console.debug?.(e);
            }
            try {
                if (upstreamSignal) upstreamSignal.removeEventListener("abort", onAbort);
            } catch (e) {
                console.debug?.(e);
            }
        },
    };
}

function _buildPendingRequestKey(method: any, url: any, options: Record<string, any> = {}) {
    const normalizedMethod = String(method || "GET")
        .trim()
        .toUpperCase();
    const normalizedUrl = String(url || "").trim();
    if (!normalizedMethod || !normalizedUrl) return "";
    const timeoutMs = _resolveFetchTimeoutMs(options);
    return `${normalizedMethod}:${normalizedUrl}:timeout=${timeoutMs}`;
}

function _deduplicatedFetch(key: any, fetcher: any) {
    const normalizedKey = String(key || "").trim();
    if (!normalizedKey) return fetcher();
    if (_pendingRequests.has(normalizedKey)) {
        return _pendingRequests.get(normalizedKey);
    }
    const pending = Promise.resolve()
        .then(() => fetcher())
        .finally(() => {
            try {
                _pendingRequests.delete(normalizedKey);
            } catch (e) {
                console.debug?.(e);
            }
        });
    _pendingRequests.set(normalizedKey, pending);
    return pending;
}

/**
 * Inject CSRF and observability headers in-place.
 *
 * Mutates `headers` directly (avoids an extra async tick that would break
 * deduplication timing if this were an async helper).
 *
 * @param {Headers|object} headers - Mutable headers object.
 * @param {string} method - Uppercased HTTP method.
 * @param {boolean} obsEnabled - Whether observability is on.
 */
function _injectBaseHeaders(headers: any, method: any, obsEnabled: any) {
    // 1. CSRF protection  -  required on all write methods.
    if (_methodIsWrite(method)) {
        try {
            if (headers instanceof Headers) {
                if (!headers.has("X-Requested-With"))
                    headers.set("X-Requested-With", "XMLHttpRequest");
            } else if (!headers["X-Requested-With"]) {
                headers["X-Requested-With"] = "XMLHttpRequest";
            }
        } catch (e) {
            console.debug?.(e);
        }
    }
    // 2. Observability opt-in.
    try {
        if (headers instanceof Headers) {
            if (!headers.has("X-MJR-OBS")) headers.set("X-MJR-OBS", obsEnabled ? "on" : "off");
        } else if (!("X-MJR-OBS" in headers)) {
            headers["X-MJR-OBS"] = obsEnabled ? "on" : "off";
        }
    } catch (e) {
        console.debug?.(e);
    }
}

/**
 * Inject auth token headers in-place.
 *
 * @param {Headers|object} headers - Mutable headers object.
 * @param {string} authToken - Token value (empty string -> no-op).
 */
function _injectAuthToken(headers: any, authToken: any) {
    if (!authToken) return;
    try {
        if (headers instanceof Headers) {
            if (!headers.has("X-MJR-Token")) headers.set("X-MJR-Token", authToken);
            if (!headers.has("Authorization")) headers.set("Authorization", `Bearer ${authToken}`);
        } else {
            if (!("X-MJR-Token" in headers)) headers["X-MJR-Token"] = authToken;
            if (!("Authorization" in headers)) headers["Authorization"] = `Bearer ${authToken}`;
        }
    } catch (e) {
        console.debug?.(e);
    }
}

/**
 * Parse a fetch Response and handle auth refresh if needed.
 *
 * Covers:
 *   - Non-JSON content-type -> INVALID_RESPONSE (with optional auth-retry on 401).
 *   - JSON parse failure -> INVALID_RESPONSE.
 *   - AUTH_REQUIRED / 401 JSON response -> one-shot auth refresh + retry.
 *   - Write-auth failure normalization.
 *
 * @param {Response} response
 * @param {string} url
 * @param {string} method
 * @param {boolean} authRetryDone - True when this is already the retry after an auth refresh.
 * @param {object} deps - { ensureWriteAuthToken, normalizeWriteAuthFailure, fetchAPI, options, retryCount }
 * @returns {Promise<object>} ApiResult
 */
async function _handleResponse(
    response: any,
    url: any,
    method: any,
    authRetryDone: any,
    { ensureWriteAuthToken, normalizeWriteAuthFailure, fetchAPI, options, retryCount }: Record<string, any>,
) {
    const contentType = response.headers.get("content-type") || "";

    if (!contentType.includes("application/json")) {
        // On a non-JSON 401, try one auth refresh before giving up.
        if (
            !authRetryDone &&
            _methodIsWrite(method) &&
            _isMajoorApiUrl(url) &&
            !_isBootstrapTokenUrl(url) &&
            Number(response.status || 0) === 401
        ) {
            const refreshed = await ensureWriteAuthToken({ force: true, allowCookieRefresh: true });
            if (refreshed) {
                return await fetchAPI(url, { ...options, _authRetryDone: true }, retryCount);
            }
        }
        return {
            ok: false,
            error: `Server returned non-JSON response (${response.status})`,
            code: "INVALID_RESPONSE",
            status: response.status,
            content_type: contentType,
            data: null,
        };
    }

    let result = await response.json().catch((e: any) => {
        console.debug?.("[MJR API] JSON parse error:", e);
        return null;
    });

    if (typeof result !== "object" || result === null) {
        return {
            ok: false,
            error: "Invalid response structure",
            code: "INVALID_RESPONSE",
            status: response.status,
            data: null,
        };
    }

    if (!("status" in result)) {
        try {
            result.status = response.status;
        } catch (e) {
            console.debug?.(e);
        }
    }

    // On an AUTH_REQUIRED / 401 JSON response, try one token refresh + retry.
    const shouldTryAuthRefresh =
        !authRetryDone &&
        _methodIsWrite(method) &&
        !_isBootstrapTokenUrl(url) &&
        !result?.ok &&
        (String(result?.code || "").toUpperCase() === "AUTH_REQUIRED" ||
            Number(result?.status || 0) === 401);

    if (shouldTryAuthRefresh) {
        const refreshed = await ensureWriteAuthToken({ force: true, allowCookieRefresh: true });
        if (refreshed) {
            return await fetchAPI(url, { ...options, _authRetryDone: true }, retryCount);
        }
    }

    if (_methodIsWrite(method) && _isMajoorApiUrl(url) && !_isBootstrapTokenUrl(url)) {
        result = normalizeWriteAuthFailure(result);
    }

    return result;
}

/**
 * Create the shared API fetch helpers used by the Majoor client.
 */
export function createApiFetchClient({
    readObsEnabled = () => false,
    readAuthToken = () => "",
    ensureWriteAuthToken = async () => "",
    normalizeWriteAuthFailure = (result: any) => result,
    trackApiCall = null,
}: {
    readObsEnabled?: () => boolean;
    readAuthToken?: () => string;
    ensureWriteAuthToken?: (...args: any[]) => Promise<string>;
    normalizeWriteAuthFailure?: (result: any) => any;
    trackApiCall?: ((duration: number, failed: boolean) => void) | null;
} = {}) {
    async function fetchAPI(url: string, options: Record<string, any> = {}, retryCount = 0) {
        const apiStartTime = typeof performance !== "undefined" ? performance.now() : Date.now();
        const timed = _buildTimedSignal(options);
        let result = null;
        try {
            const method = (options.method || "GET").toUpperCase();
            const authRetryDone = !!options?._authRetryDone;

            // Build headers synchronously so no extra microtask tick is added
            // before await fetch()  -  this preserves deduplication timing.
            const headers =
                typeof Headers !== "undefined"
                    ? new Headers(options.headers || {})
                    : { ...options.headers };
            _injectBaseHeaders(headers, method, !!readObsEnabled());

            // Bootstrap auth token for write requests only when missing.
            let authToken = readAuthToken();
            if (
                !authToken &&
                _methodIsWrite(method) &&
                _isMajoorApiUrl(url) &&
                !_isBootstrapTokenUrl(url)
            ) {
                try {
                    await ensureWriteAuthToken();
                } catch (e) {
                    console.debug?.(e);
                }
                authToken = readAuthToken();
            }
            _injectAuthToken(headers, authToken);

            const fetchOptions: Record<string, any> = { ...options, headers, signal: timed.signal };
            try {
                delete fetchOptions._authRetryDone;
                delete fetchOptions.timeoutMs;
            } catch (e) {
                console.debug?.(e);
            }

            const response = await fetch(url, fetchOptions);
            result = await _handleResponse(response, url, method, authRetryDone, {
                ensureWriteAuthToken,
                normalizeWriteAuthFailure,
                fetchAPI,
                options,
                retryCount,
            });
            return result;
        } catch (error: any) {
            try {
                if (String(error?.name || "") === "AbortError") {
                    if (options?.signal && options.signal.aborted) {
                        return { ok: false, error: "Aborted", code: "ABORTED", data: null };
                    }
                    return {
                        ok: false,
                        error: `Request timed out after ${timed.timeoutMs}ms`,
                        code: "TIMEOUT",
                        data: null,
                        timeout_ms: timed.timeoutMs,
                    };
                }
            } catch (e) {
                console.debug?.(e);
            }
            if (retryCount < MAX_RETRIES && _isRetryableError(error)) {
                try {
                    await delay(RETRY_BASE_DELAY_MS * (retryCount + 1));
                } catch (e) {
                    console.debug?.(e);
                }
                // fetchAPI catches all errors internally  -  no try/catch needed here.
                // A wrapping try/catch would swallow the retry result and fall through
                // to the NETWORK_ERROR return below with the *original* error instead.
                return await fetchAPI(url, options, retryCount + 1);
            }
            return {
                ok: false,
                error: (error as any)?.message || String(error || "Network error"),
                code: "NETWORK_ERROR",
                data: null,
                retries: retryCount,
            };
        } finally {
            try {
                const apiEndTime =
                    typeof performance !== "undefined" ? performance.now() : Date.now();
                const duration = apiEndTime - apiStartTime;
                if (typeof trackApiCall === "function") {
                    trackApiCall(duration, !result?.ok);
                } else if (typeof window !== "undefined" && (window as any).MajoorMetrics) {
                    (window as any).MajoorMetrics.trackApiCall(duration, !result?.ok);
                }
            } catch (e) {
                console.debug?.(e);
            }
            try {
                timed.cleanup?.();
            } catch (e) {
                console.debug?.(e);
            }
        }
    }

    async function get(url: string, options: Record<string, any> = {}) {
        const dedupeKey =
            options?.dedupe === false
                ? ""
                : String(options?.dedupeKey || "").trim() ||
                  _buildPendingRequestKey("GET", url, options);
        return _deduplicatedFetch(dedupeKey, () => fetchAPI(url, { ...options, method: "GET" }));
    }

    async function post(url: string, body: unknown, options: Record<string, any> = {}) {
        return fetchAPI(url, {
            ...options,
            method: "POST",
            headers: { "Content-Type": "application/json", ...options.headers },
            body: JSON.stringify(body),
        });
    }

    return { fetchAPI, get, post };
}
