import { useAppStore, type User } from "../../store";
import { t } from "../../i18n";
import { apiFetch } from "../../net/api";
import { isDesktop, desktopRefresh, clearDesktopSession } from "../../net/desktop";
import { fetchMeScope } from "../../net/sessions";
import { toast } from "../../ui/feedback";

// Auth, post Auth Broker migration (path A). cdrop no longer runs OIDC itself:
//   - a brand-new browser logs in via the broker's global SSO (loginRedirect → cdrop
//     302s to the broker login page; on return the edge injects X-Auth from the broker
//     domain cookie, so this browser holds NO token — the cookie carries identity);
//   - a QR-paired device holds the broker's access + refresh pair directly and renews
//     them via /api/auth/refresh (a thin same-origin proxy to the broker).

// ---- dev mode -------------------------------------------------------------

// Dev-mode "login": read ?dev_user=alice (or fall back to localStorage), stamp the
// store with a synthetic User and the build-time dev token. Bypasses the broker; the
// backend dev middleware accepts any X-Dev-User as long as the bearer matches
// CDROP_DEV_TOKEN.
export function loginDev(searchParams: URLSearchParams): User
{
    const devUser = searchParams.get("dev_user")
        ?? window.localStorage.getItem("cdrop.dev_user")
        ?? "dev-user";
    window.localStorage.setItem("cdrop.dev_user", devUser);

    const token = import.meta.env.VITE_CDROP_DEV_TOKEN;
    if (!token)
    {
        throw new Error(t("errors.devTokenMissing"));
    }

    const user: User = { id: devUser, name: devUser };
    useAppStore.getState().setAuth({ accessToken: token, refreshToken: null, user });
    return user;
}

// ---- prod login (broker global SSO) ---------------------------------------

// loginRedirect sends the browser to the broker's global-SSO login via cdrop's
// server-side 302 (which holds the broker's public URL). After login the browser
// returns here with a broker domain cookie the edge turns into X-Auth.
export function loginRedirect(): void
{
    const rd = encodeURIComponent(window.location.href);
    window.location.href = `/api/auth/login?rd=${rd}`;
}

// ---- logout ---------------------------------------------------------------

export async function logout(): Promise<void>
{
    // Fire-and-forget: tell the backend to kick this device from the Hub immediately
    // (so peers don't wait out the 30s grace). apiFetch reads the access token before
    // yielding, so the subsequent clearAuth can't strip this request's Authorization.
    void apiFetch("/api/me/disconnect", { method: "POST" }).catch(() => { /* ignore */ });
    // Revoke this device's own broker session server-side BEFORE clearing local credentials,
    // so it can't be refreshed back. This must be AWAITED, not fire-and-forget: on desktop the
    // refresh token lives in the Go process and clearDesktopSession() drops it — if we cleared
    // first, an expired-access 401 here could no longer refresh-and-retry and the broker session
    // would leak (the "桌面登出静默失败" half that isn't the missing confirm dialog). apiFetch
    // transparently refreshes the access token for this call. Global-SSO browser → server no-op.
    if (useAppStore.getState().authMode === "prod")
    {
        try { await apiFetch("/api/auth/logout", { method: "POST" }); }
        catch { /* best-effort; clear local state regardless */ }
    }
    useAppStore.getState().clearAuth();
    // Desktop: drop the Go-side persisted session, else the next launch re-injects it.
    if (isDesktop()) { clearDesktopSession(); }
}

// forceLogout clears local auth state when the server authoritatively confirms the
// session is gone (refresh → 401). It sends no request (the session is dead) and just
// clears + toasts; the __root guard then returns the UI to the login page. Idempotent:
// concurrent 401s after the first clear see an empty state and return early.
export function forceLogout(): void
{
    const st = useAppStore.getState();
    if (!st.user && !st.accessToken)
    {
        return;
    }
    st.clearAuth();
    if (isDesktop()) { clearDesktopSession(); }
    toast.error(t("auth.sessionLost.title"), t("auth.sessionLost.body"));
}

// ---- token refresh (broker proxy) -----------------------------------------

interface BrokerRefreshResp
{
    access_token: string;
    refresh_token: string;
    expires_in: number;
}

type BrokerRefreshResult =
    | { kind: "ok"; accessToken: string; refreshToken: string }
    | { kind: "invalid" }
    | { kind: "transient" };

// brokerRefresh exchanges the stored refresh token for a fresh access + rotated
// refresh via cdrop's same-origin proxy to the broker. 401 → invalid (expired /
// rotated / revoked); other non-OK / malformed → transient (keep the session).
async function brokerRefresh(refreshToken: string): Promise<BrokerRefreshResult>
{
    let r: Response;
    try
    {
        r = await fetch("/api/auth/refresh", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ refresh_token: refreshToken }),
        });
    }
    catch
    {
        return { kind: "transient" };
    }
    if (!r.ok) { return { kind: r.status === 401 ? "invalid" : "transient" }; }
    const data = await r.json().catch(() => null) as BrokerRefreshResp | null;
    if (!data?.access_token || !data.refresh_token) { return { kind: "transient" }; }
    return { kind: "ok", accessToken: data.access_token, refreshToken: data.refresh_token };
}

// RefreshOutcome is the three-state conclusion refresh reports upward; only "invalid"
// (server-confirmed dead) lets the caller forceLogout, "transient" must keep state.
export type RefreshOutcome = "refreshed" | "invalid" | "transient";

// Single-flight guard. The broker rotates refresh tokens single-use: a refresh consumes
// the current refresh token and returns a fresh pair, invalidating the old one. On a cold
// boot after a long gap (iOS engine reopened next morning) the access token is expired, so
// the boot's concurrent authed calls (SSE + /api/me + ICE + scope) all 401 at once. Without
// this guard each 401 calls refreshTokens() in parallel with the SAME stored refresh token:
// the first rotates it, the rest replay the now-invalidated token → broker 401 → "invalid"
// → forceLogout → a spurious re-login every morning. Collapsing concurrent refreshes onto a
// single in-flight promise means one rotation, one new pair, and every waiter retries with
// the fresh access token. (Sequential refreshes are safe — each uses the latest token.)
let refreshInFlight: Promise<RefreshOutcome> | null = null;

// refreshTokens renews the access token; api.ts calls it lazily on a 401. Concurrent callers
// share one in-flight refresh (see refreshInFlight) so a single-use rotating refresh token is
// consumed exactly once.
export function refreshTokens(): Promise<RefreshOutcome>
{
    if (refreshInFlight) { return refreshInFlight; }
    refreshInFlight = doRefreshTokens().finally(() => { refreshInFlight = null; });
    return refreshInFlight;
}

async function doRefreshTokens(): Promise<RefreshOutcome>
{
    const store = useAppStore.getState();
    if (store.authMode !== "prod") { return "transient"; }

    // Desktop: refresh happens entirely inside Go (the refresh token lives in the Go
    // process, never in JS). Go does not yet distinguish revoked from transient, so a
    // failure is treated as transient (keeps state).
    if (isDesktop())
    {
        const res = await desktopRefresh();
        if (!res?.access_token) { return "transient"; }
        const cur = useAppStore.getState();
        if (!cur.user) { return "transient"; }
        cur.setAuth({ accessToken: res.access_token, user: cur.user });
        return "refreshed";
    }

    // After 代铸 a browser holds a device-session refresh token and renews via the broker
    // proxy below. A browser still on cookie-only identity (代铸 not yet run / failed) holds
    // none; a 401 then means the broker cookie is gone too: invalid → re-login.
    const refresh = store.refreshToken;
    if (!refresh) { return "invalid"; }

    const res = await brokerRefresh(refresh);
    if (res.kind !== "ok") { return res.kind; }
    const cur = useAppStore.getState();
    if (!cur.user) { return "transient"; }
    cur.setAuth({ accessToken: res.accessToken, refreshToken: res.refreshToken, user: cur.user });
    return "refreshed";
}

// ---- session bootstrap (/api/me) ------------------------------------------

interface MeResp
{
    user_id: string;
    name: string;
    avatar?: string;
    device_name?: string;
    scope?: string;
}

// fetchMe confirms the session with the server: a QR device sends its stored Bearer, a
// global-SSO browser relies on the broker cookie (no header). Returns null on any
// non-200 / error, so the caller falls to the login page.
async function fetchMe(): Promise<MeResp | null>
{
    const token = useAppStore.getState().accessToken;
    const headers: Record<string, string> = {};
    if (token) { headers.Authorization = `Bearer ${token}`; }
    let r: Response;
    try { r = await fetch("/api/me", { headers }); }
    catch { return null; }
    if (!r.ok) { return null; }
    return await r.json().catch(() => null) as MeResp | null;
}

// bootstrapAuth runs before the app mounts: it confirms the session via /api/me and
// hydrates the identity (a stored device token is preserved; a global-SSO browser has
// none). Failure leaves the app logged out → login page. Desktop hydrates from boot
// injection and dev has no broker session; both skip.
export async function bootstrapAuth(): Promise<void>
{
    const store = useAppStore.getState();
    if (store.authMode !== "prod") { return; }

    if (isDesktop())
    {
        // Desktop identity (name + avatar) comes from the Go boot injection, which heals a stale
        // persisted identity from /api/me — refreshing the token if needed and re-persisting —
        // BEFORE injecting it (see desktop HealIdentity). Doing it Go-side, at the source, is why
        // it no longer depends on a fragile per-launch WebView /api/me. Nothing to do here.
        return;
    }

    const me = await fetchMe();
    if (!me?.user_id) { return; }
    const cur = useAppStore.getState();
    // Use /api/me's name only when it is a real display name. The server falls back to the
    // subject UUID when X-Auth-Name is absent; in that case keep an already-known name (e.g.
    // rehydrated from a prior login) rather than clobbering it with the UUID.
    const verifiedName = me.name && me.name !== me.user_id ? me.name : "";
    cur.setAuth({
        accessToken: cur.accessToken,   // preserve a stored device token, or null (cookie)
        user: {
            id: me.user_id,
            name: verifiedName || cur.user?.name || me.user_id,
            avatar: me.avatar || cur.user?.avatar,
        },
    });
    cur.setSessionScope(me.scope === "guest" ? "guest" : "full");
    if (me.device_name && !cur.selfDeviceName) { cur.setSelfDeviceName(me.device_name); }
    // Upgrade a global-SSO browser into a cdrop-managed device session (代铸) so it joins
    // the unified device list and rides a Bearer token like a QR-paired device. No-op if it
    // already holds a token or has no device name yet (first-time setup mints it on naming).
    await ensureDeviceSession();
}

// ---- 代铸 (managed device session) ----------------------------------------

// DEVICE_ID_KEY persists this browser's stable cdrop device_id (the broker `meta` /
// session<->device join key). It survives sessionStorage token loss so a re-login rotates
// the same session (broker R2 idempotency) instead of spawning a duplicate device.
const DEVICE_ID_KEY = "cdrop.device_id";

interface DeviceSessionResp
{
    access_token: string;
    refresh_token: string;
    expires_in: number;
    device_id: string;
    device_name: string;
    name: string;
}

// ensureDeviceSession turns a global-SSO browser (cookie identity, no token) into a
// cdrop-managed device session: the backend 代铸s a session bound to this browser's stable
// device_id, so the browser appears in the unified device list exactly like a QR-paired
// device and rides a Bearer token thereafter. Idempotent and best-effort:
//   - dev / desktop / not-logged-in → skip;
//   - already holds a token (QR device or a prior 代铸) → skip;
//   - no device name yet → skip (first-time setup calls this again after naming);
//   - on any failure the browser keeps working via the broker cookie (degraded: it just
//     won't show in the device list until a later 代铸 succeeds).
export async function ensureDeviceSession(): Promise<void>
{
    const st = useAppStore.getState();
    if (st.authMode !== "prod" || isDesktop()) { return; }
    if (!st.user) { return; }
    if (st.accessToken) { return; }
    if (!st.selfDeviceName) { return; }

    // Serialize 代铸 across all same-origin tabs. Without this, two tabs (or a reload before the
    // first mint persists device_id) each 代铸 with an EMPTY device_id, and the server mints a
    // fresh id for each → duplicate phantom devices (the very regression this rework fixes). The
    // first lock holder mints and persists device_id; later holders read the seeded id and the
    // broker R2-rotates the one session. Web Locks coordinate across tabs; absent the API, fall
    // back to a best-effort single call.
    // Wrap in an arrow so the LockManager callback's Lock argument is not passed on as the
    // mintDeviceSession `force` flag.
    if (typeof navigator !== "undefined" && navigator.locks)
    {
        await navigator.locks.request("cdrop.device-session", () => mintDeviceSession());
    }
    else
    {
        await mintDeviceSession();
    }
}

// renameDevice changes this device's display name WITHOUT touching its session token — the
// decoupled rename. It PATCHes /api/devices/{device_id}; the server updates the name keyed by
// the stable device_id and re-broadcasts presence, so every peer sees the new name at once with
// no token rotation, no SSE interruption, and no duplicate device row. Falls back to a 代铸
// re-mint only when this browser has no managed device session yet (cookie-only, pre-代铸) or the
// row is unexpectedly gone (404). Caller has already updated selfDeviceName (drives X-Device-Name).
export async function renameDevice(): Promise<void>
{
    const st = useAppStore.getState();
    if (st.authMode !== "prod") { return; }
    const name = st.selfDeviceName;
    if (!name) { return; }

    const storedId = (typeof window !== "undefined")
        ? (window.localStorage.getItem(DEVICE_ID_KEY) ?? "")
        : "";
    // No managed device session yet (no token / no device_id): adopt the name at 代铸 instead.
    if (!storedId || (!st.accessToken && !isDesktop()))
    {
        await renameDeviceSession();
        return;
    }

    let r: Response;
    try
    {
        r = await apiFetch(`/api/devices/${encodeURIComponent(storedId)}`, {
            method: "PATCH",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ name }),
        });
    }
    catch
    {
        // Transient network error: the local name is already set and rides X-Device-Name; a
        // later presence refresh / reconnect reconciles. Don't fall back to a token-rotating
        // re-mint on a transient error.
        return;
    }
    // 404 = the device row is gone (e.g. revoked elsewhere): re-mint to re-register under the name.
    if (r.status === 404) { await renameDeviceSession(); }
}

// renameDeviceSession re-代铸s with the (already-updated) selfDeviceName and the same stable
// device_id, so the broker label — hence the unified device list and presence — reflects the
// new name. R2 rotates the one session in place (no duplicate device row); the rotated tokens
// replace the browser's current ones. No-op unless this browser already holds a device session
// (a cookie-only browser adopts the name at its first 代铸; desktop renames in its own client).
export async function renameDeviceSession(): Promise<void>
{
    const st = useAppStore.getState();
    if (st.authMode !== "prod" || isDesktop()) { return; }
    if (!st.user || !st.accessToken || !st.selfDeviceName) { return; }
    await mintDeviceSession(true);
}

async function mintDeviceSession(force = false): Promise<void>
{
    // Re-read inside the lock: another tab may have just seeded device_id (or this tab may have
    // gained a token meanwhile). force=true is the rename path: re-mint even though a token is
    // already held, to push the new label to the broker.
    const st = useAppStore.getState();
    if (!force && st.accessToken) { return; }
    const deviceName = st.selfDeviceName;
    if (!deviceName) { return; }

    const storedId = window.localStorage.getItem(DEVICE_ID_KEY) ?? "";
    let r: Response;
    try
    {
        // Raw fetch (not apiFetch): identity rides the broker cookie, and a 401 here must
        // not trigger the refresh→forceLogout path (there is no refresh token yet).
        r = await fetch("/api/auth/device-session", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({
                device_id: storedId,
                device_name: deviceName,
                device_type: "browser",
            }),
        });
    }
    catch { return; }
    if (!r.ok) { return; }
    const data = await r.json().catch(() => null) as DeviceSessionResp | null;
    if (!data?.access_token || !data.device_id) { return; }

    window.localStorage.setItem(DEVICE_ID_KEY, data.device_id);
    const cur = useAppStore.getState();
    if (!cur.user) { return; }
    cur.setAuth({
        accessToken: data.access_token,
        refreshToken: data.refresh_token,
        user: cur.user,
    });
}

// refreshSessionScope re-reads /api/me to correct the session's capability level
// (full / guest) into the store, so a restricted guest's account-management entries
// stay hidden. Best-effort; failure falls back to "full" (entries only show, backend
// still 403s). dev has no guest concept; skip.
export async function refreshSessionScope(): Promise<void>
{
    const store = useAppStore.getState();
    if (store.authMode !== "prod") { return; }
    if (!store.user) { return; }
    const scope = await fetchMeScope();
    useAppStore.getState().setSessionScope(scope);
}

// syncWebDeviceName is retained as a no-op after the migration: a QR-paired device's
// name is set server-side at pairing (and renamed via the device API); a global-SSO
// browser keeps no managed device row. The local selfDeviceName still drives presence.
export function syncWebDeviceName(_name: string): void
{
    /* no-op: see comment above */
}
