/**
 * AuthService — provider-agnostic sign-in for the React prototype (board card I-04,
 * architecture/cognito-migration-plan-2026-10-02.html §6.1, §8, D4).
 *
 * ONE interface, two engines, picked by the AUTH_PROVIDER flag
 * (components/FeatureFlags/feature-flags.json):
 *
 *   "cognito" (default) — Amazon Cognito managed login, OAuth 2.0 authorization
 *                         code + PKCE (RFC 7636), public client, no secret. Hand-
 *                         written (~400 lines) rather than oidc-client-ts; see
 *                         "Why hand-written" below.
 *   "clerk"             — the previous Clerk integration, kept as an instant
 *                         fallback. Its two script bundles are injected ONLY when
 *                         this engine is selected (they used to be unconditional
 *                         <script defer> tags in index.html, which is the load-time
 *                         cost the move to Cognito exists to remove).
 *
 * Imperative API — window.mdAuth (usable outside React: SyncService, MetricsService):
 *   init()          → Promise, idempotent. Picks the engine, finishes a pending
 *                     callback, restores a kept session. Never rejects.
 *   signIn(opts)    → leaves the page for managed login (same tab).
 *                     opts: { mode: 'signin'|'signup', returnTo: '/route' }
 *   signOut()       → local clear + token revocation + managed-login /logout.
 *   getToken()      → Promise<access token | null>. NEVER rejects. Refreshes first
 *                     when the token is within 60 s of expiry. Call per request.
 *   getUser()       → { sub, email, displayName, ... } or null.
 *   onChange(cb)    → unsubscribe fn. cb() on every state change.
 *   isSignedIn()    → boolean.   status() → 'loading' | 'signedIn' | 'signedOut'.
 *   keepSignedIn() / setKeepSignedIn(bool) — the opt-in preference (Cognito only).
 *
 * React API: <AuthProvider> + useAuth() → { isLoaded, isSignedIn, user, provider,
 *   keepSignedIn, setKeepSignedIn, openSignIn, openSignUp, signOut, getToken }.
 *   `useClerkAuth` / `ClerkProvider` remain as aliases for one release so code in
 *   other branches (and other worktrees' test harnesses) keeps working.
 *
 * TOKEN STORAGE (plan D4, risk R8) — the trade-off, stated plainly:
 *   • Access + ID tokens live in memory only. Nothing an injected script could
 *     lift from storage after the tab closes.
 *   • The refresh token is ALSO memory-only unless the person ticks "Keep me
 *     signed in" (default off). Ticked, it is written to localStorage and capped
 *     at 7 days from the interactive sign-in (the web client's refresh lifetime),
 *     whatever rotation does. That is the convenience/safety switch: the app's CSP
 *     must allow 'unsafe-eval' for in-browser Babel, so CSP cannot stop an injected
 *     script reading localStorage — opt-in, short-lived and rotating is what is left.
 *     A backend-for-frontend (HttpOnly cookie) is the fix if this app ever becomes a
 *     production product (plan §8).
 *   • Not ticked, a reload would lose the session. Within the managed-login cookie's
 *     1-hour window we re-run the authorize redirect once, which Cognito answers
 *     without showing a page (a ~0.5 s bounce); after that window a reload is signed
 *     out. A non-secret marker in sessionStorage ({at}) is all that drives this.
 *
 * Why hand-written rather than oidc-client-ts: the flow is five requests to fixed,
 * documented endpoints; the library would be another pinned CDN script + SRI hash,
 * a discovery fetch to cognito-idp on every load, and a custom StateStore to get the
 * D4 storage rule — more code to adapt than to write. PKCE and state are the parts
 * worth testing, so they are exported pure (window.mdAuthPkce) and covered by
 * headless-browser-integration-tests/auth.js. ID-token SIGNATURE is not checked in
 * the browser: the token comes straight from the token endpoint over TLS, which
 * OIDC Core §3.1.3.7 accepts in place of a signature check; iss/aud/exp/nonce are
 * checked. APIs (sync Lambda, chat) verify access tokens properly server-side.
 *
 * Callback: Cognito returns to <origin>/auth/callback. CloudFront has no SPA
 * fallback, so auth/callback/index.html is a tiny shim that forwards the query to
 * "/"; this file reads it at load (see mdAuthBoot), strips it from the address bar,
 * and AuthProvider finishes the exchange.
 */

/* ------------------------------------------------------------------ */
/* Config (non-secret; overridable via window.MD_AUTH_CONFIG)          */
/* ------------------------------------------------------------------ */

var MD_AUTH_DEFAULTS = {
    domain:      'https://mould-detect-dev.auth.ap-southeast-2.amazoncognito.com',
    clientId:    '2dp30vclgesn01vb5ovuf9hge2',
    issuer:      'https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_MnZdN0LBy',
    // Q-01 F8: no aws.cognito.signin.user.admin — nothing here calls the Cognito
    // user APIs (UpdateUserAttributes etc.) with the access token, so the token
    // should not be able to. The sync Lambda checks token_use + client_id only.
    scope:       'openid email profile',
    callbackPath: '/auth/callback',
    logoutPath:  '/',
    keepMaxMs:   7 * 24 * 60 * 60 * 1000,   // plan D4 / §6.4: web refresh lifetime 7 days
    ssoWindowMs: 50 * 60 * 1000,            // managed-login cookie is 1 h; stay inside it
    txnMaxAgeMs: 15 * 60 * 1000,            // a sign-in round trip older than this is stale
    refreshSkewMs: 60 * 1000,               // refresh this long before access-token expiry
    clockSkewSec: 300,                      // Q-01 F7: leeway on ID-token exp/iat for a wrong device clock
    initWaitMs:  20 * 1000,                 // Q-01 F2: getToken() waits at most this long for init
    clerkPublishableKey: 'pk_test_dW5pdGVkLWRvZS0zNi5jbGVyay5hY2NvdW50cy5kZXYk',
    clerkHost:   'https://united-doe-36.clerk.accounts.dev'
};

function mdAuthConfig() {
    var override = (typeof window !== 'undefined' && window.MD_AUTH_CONFIG) || {};
    return Object.assign({}, MD_AUTH_DEFAULTS, override);
}

var MD_AUTH_KEYS = {
    txn:    'md.auth.txn',      // sessionStorage: {state, verifier, nonce, returnTo, keep, at}
    sso:    'md.auth.sso',      // sessionStorage: {at} — managed-login cookie window marker
    bounce: 'md.auth.bounce',   // sessionStorage: ms timestamp of the last silent bounce
    keep:   'md.auth.keep',     // localStorage: '1' | '0' — the opt-in preference
    rt:     'md.auth.rt',       // localStorage: {rt, since} — ONLY when keep is on
    route:  'md.auth.route'     // sessionStorage: last in-app route (silent bounce returns there)
};

/* Storage that never throws (private mode, blocked site data). */
function mdAuthStore(kind) {
    var s = null;
    try { s = window[kind]; } catch (e) { s = null; }
    return {
        get: function (k) { try { return s ? s.getItem(k) : null; } catch (e) { return null; } },
        set: function (k, v) { try { if (s) s.setItem(k, v); } catch (e) { /* ignore */ } },
        del: function (k) { try { if (s) s.removeItem(k); } catch (e) { /* ignore */ } },
        getJSON: function (k) {
            try { var raw = s ? s.getItem(k) : null; return raw ? JSON.parse(raw) : null; } catch (e) { return null; }
        },
        setJSON: function (k, v) { try { if (s) s.setItem(k, JSON.stringify(v)); } catch (e) { /* ignore */ } }
    };
}

/* ------------------------------------------------------------------ */
/* PKCE + state + claims — pure helpers (unit-tested by auth.js)       */
/* ------------------------------------------------------------------ */

var mdAuthPkce = (function () {
    function base64url(bytes) {
        var bin = '';
        for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
        return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
    }

    function randomString(byteLength) {
        var bytes = new Uint8Array(byteLength);
        window.crypto.getRandomValues(bytes);
        return base64url(bytes);
    }

    /** RFC 7636 §4.1: 43–128 unreserved chars. 48 random bytes → 64 chars. */
    function createVerifier() { return randomString(48); }

    /** 32 random bytes → 43 chars; unguessable, single use. */
    function createState() { return randomString(32); }

    /** S256 challenge: BASE64URL(SHA-256(ASCII(verifier))). */
    function challengeFor(verifier) {
        var data = new TextEncoder().encode(verifier);
        return window.crypto.subtle.digest('SHA-256', data).then(function (buf) {
            return base64url(new Uint8Array(buf));
        });
    }

    function isValidVerifier(v) {
        return typeof v === 'string' && /^[A-Za-z0-9\-._~]{43,128}$/.test(v);
    }

    /** Constant-time comparison of the returned state against the one we sent. */
    function stateMatches(expected, actual) {
        if (typeof expected !== 'string' || typeof actual !== 'string') return false;
        if (expected.length < 16 || expected.length !== actual.length) return false;
        var diff = 0;
        for (var i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
        return diff === 0;
    }

    function decodeJwt(token) {
        try {
            var part = String(token).split('.')[1];
            if (!part) return null;
            var b64 = part.replace(/-/g, '+').replace(/_/g, '/');
            while (b64.length % 4) b64 += '=';
            var json = decodeURIComponent(Array.prototype.map.call(atob(b64), function (c) {
                return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
            }).join(''));
            var claims = JSON.parse(json);
            return (claims && typeof claims === 'object') ? claims : null;
        } catch (e) {
            return null;
        }
    }

    /**
     * validateIdClaims — null when acceptable, else a short reason. Signature is
     * not checked here (see the file header); issuer, audience, expiry and the
     * nonce we sent are.
     *
     * Nonce (Q-01 F12): when we sent one (the code exchange), the ID token MUST
     * echo it — Cognito always does, so an absent nonce is refused
     * ('nonce_missing') rather than waved through. Refresh responses are checked
     * with no expected nonce (Cognito does not carry it into refreshed tokens).
     *
     * Clock skew (Q-01 F7): exp and iat are compared with `expect.skewSec`
     * (default 300 s) of leeway, so a device clock a few minutes out does not
     * make sign-in impossible. The token came straight from the token endpoint
     * over TLS, so the leeway costs nothing in replay terms.
     */
    function validateIdClaims(claims, expect) {
        if (!claims) return 'id_token_unreadable';
        if (claims.iss !== expect.issuer) return 'iss_mismatch';
        var aud = claims.aud;
        var audOk = Array.isArray(aud) ? aud.indexOf(expect.clientId) !== -1 : aud === expect.clientId;
        if (!audOk) return 'aud_mismatch';
        if (claims.token_use && claims.token_use !== 'id') return 'not_an_id_token';
        var now = Math.floor((expect.now || Date.now()) / 1000);
        var skew = (typeof expect.skewSec === 'number' && expect.skewSec >= 0) ? expect.skewSec : 300;
        if (typeof claims.exp !== 'number' || claims.exp + skew <= now) return 'expired';
        if (typeof claims.iat === 'number' && claims.iat - skew > now) return 'issued_in_future';
        if (expect.nonce) {
            if (claims.nonce === undefined || claims.nonce === null) return 'nonce_missing';
            if (claims.nonce !== expect.nonce) return 'nonce_mismatch';
        }
        if (typeof claims.sub !== 'string' || !claims.sub) return 'no_sub';
        return null;
    }

    /** Only in-app MemoryRouter paths: "/x", never "//host" or a URL. */
    function safeReturnPath(p) {
        if (typeof p !== 'string' || p.length > 300) return '/';
        if (p.charAt(0) !== '/' || p.charAt(1) === '/' || p.indexOf('\\') !== -1) return '/';
        return p;
    }

    function buildAuthorizeUrl(cfg, p) {
        var path = p.mode === 'signup' ? '/signup' : '/oauth2/authorize';
        var q = [
            ['response_type', 'code'],
            ['client_id', cfg.clientId],
            ['redirect_uri', p.redirectUri],
            ['scope', cfg.scope],
            ['state', p.state],
            ['nonce', p.nonce],
            ['code_challenge', p.challenge],
            ['code_challenge_method', 'S256']
        ];
        if (p.identityProvider) q.push(['identity_provider', p.identityProvider]);
        return cfg.domain + path + '?' + q.map(function (kv) {
            return encodeURIComponent(kv[0]) + '=' + encodeURIComponent(kv[1]);
        }).join('&');
    }

    return {
        base64url: base64url,
        randomString: randomString,
        createVerifier: createVerifier,
        createState: createState,
        challengeFor: challengeFor,
        isValidVerifier: isValidVerifier,
        stateMatches: stateMatches,
        decodeJwt: decodeJwt,
        validateIdClaims: validateIdClaims,
        safeReturnPath: safeReturnPath,
        buildAuthorizeUrl: buildAuthorizeUrl
    };
})();

/* ------------------------------------------------------------------ */
/* Boot capture — read the forwarded callback BEFORE anything renders  */
/* ------------------------------------------------------------------ */

/**
 * Runs once at script load. If the address bar carries an OAuth response
 * (?code|error … &state …) it is captured here and stripped immediately, so the
 * code never lingers in history, analytics page views or a copied link.
 *
 * The transaction is taken (single use) from sessionStorage ONLY when the
 * returned state matches it (Q-01 F11). A crafted link carrying someone else's
 * ?code=…&state=… opened in this tab mid-sign-in is captured with txn: null (so
 * finishCallback reports state_mismatch) and leaves the real transaction in
 * place for the genuine response that is still on its way.
 *
 * It also reads the last in-app route (md.auth.route) BEFORE RouteTracker
 * overwrites it with '/', so a silent bounce can return there (Q-01 F12).
 */
var mdAuthBoot = (function () {
    var captured = null;
    var lastRoute = '/';
    try {
        var ss = mdAuthStore('sessionStorage');
        lastRoute = ss.get(MD_AUTH_KEYS.route) || '/';
        var params = new URLSearchParams(window.location.search || '');
        if (params.get('state') && (params.get('code') || params.get('error'))) {
            var txn = ss.getJSON(MD_AUTH_KEYS.txn);
            var matches = !!(txn && mdAuthPkce.stateMatches(txn.state, params.get('state')));
            captured = {
                code: params.get('code'),
                state: params.get('state'),
                error: params.get('error'),
                errorDescription: params.get('error_description'),
                txn: matches ? txn : null
            };
            if (matches) ss.del(MD_AUTH_KEYS.txn);
            try { window.history.replaceState(window.history.state, '', window.location.pathname || '/'); } catch (e) { /* ignore */ }
        }
    } catch (e) {
        captured = null;
    }
    return {
        callback: captured,
        lastRoute: function () { return mdAuthPkce.safeReturnPath(lastRoute); },
        /** Route to open the app at: the page the person signed in from, but only
         *  when the response really belongs to the transaction we started. */
        initialPath: function () {
            if (!captured || !captured.txn) return '/';
            if (!mdAuthPkce.stateMatches(captured.txn.state, captured.state)) return '/';
            return mdAuthPkce.safeReturnPath(captured.txn.returnTo);
        }
    };
})();

function mdAuthInitialPath() { return mdAuthBoot.initialPath(); }

/* ------------------------------------------------------------------ */
/* User shape shared by both engines                                   */
/* ------------------------------------------------------------------ */

function mdAuthInitials(first, last, display, email) {
    var parts = [first, last].filter(function (p) { return typeof p === 'string' && p.trim(); });
    if (parts.length) return parts.map(function (p) { return p.trim().charAt(0).toUpperCase(); }).join('').slice(0, 2);
    var words = (typeof display === 'string' ? display : '').split(/\s+/).filter(Boolean);
    if (words.length > 1 && display !== email) return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase();
    var src = (typeof email === 'string' && email) ? email : (typeof display === 'string' ? display : '');
    var ch = src.replace(/[^A-Za-z0-9]/g, '').charAt(0);
    return ch ? ch.toUpperCase() : '';
}

/** From Cognito ID-token claims. imageUrl only for an https picture (Google). */
function mdAuthUserFromClaims(c) {
    if (!c || !c.sub) return null;
    var first = typeof c.given_name === 'string' ? c.given_name : '';
    var last  = typeof c.family_name === 'string' ? c.family_name : '';
    var email = typeof c.email === 'string' ? c.email : '';
    var full  = (typeof c.name === 'string' && c.name.trim()) ? c.name.trim() : [first, last].filter(Boolean).join(' ');
    var display = full || email || 'My account';
    var picture = (typeof c.picture === 'string' && /^https:\/\//.test(c.picture)) ? c.picture : '';
    return {
        sub: c.sub,
        email: email,
        displayName: display,
        firstName: first || null,
        lastName: last || null,
        fullName: full || null,
        username: null,
        imageUrl: picture,
        initials: mdAuthInitials(first, last, full, email),
        primaryEmailAddress: email ? { emailAddress: email } : null
    };
}

/** From a Clerk user. The same shape as a Cognito user: the profile/branding
 *  mirrors go through the sync Lambda for both providers (I-08), so nothing
 *  reads Clerk `unsafeMetadata` any more. */
function mdAuthUserFromClerk(u) {
    if (!u) return null;
    var email = (u.primaryEmailAddress && u.primaryEmailAddress.emailAddress) || '';
    var full = u.fullName || [u.firstName, u.lastName].filter(Boolean).join(' ');
    return {
        sub: u.id || null,
        email: email,
        displayName: full || u.username || email || 'My account',
        firstName: u.firstName || null,
        lastName: u.lastName || null,
        fullName: full || null,
        username: u.username || null,
        imageUrl: u.imageUrl || '',
        initials: mdAuthInitials(u.firstName, u.lastName, full, email),
        primaryEmailAddress: email ? { emailAddress: email } : null
    };
}

/* ------------------------------------------------------------------ */
/* Cognito engine                                                      */
/* ------------------------------------------------------------------ */

function mdAuthCreateCognitoEngine() {
    var cfg = mdAuthConfig();
    var ss = mdAuthStore('sessionStorage');
    var ls = mdAuthStore('localStorage');

    var status = 'loading';
    var listeners = [];
    var access = null, accessExp = 0, idClaims = null, refreshToken = null;
    var keepThisSession = false, keepSince = 0;
    var refreshTimer = null, refreshing = null;
    var lastError = null;
    var initPromise = null;
    var initSettled = false;   // Q-01 F2: true once init's promise has resolved

    function emit() {
        listeners.slice().forEach(function (cb) { try { cb(); } catch (e) { /* listener faults stay theirs */ } });
    }
    function setStatus(s) { status = s; emit(); }

    function redirectUri() { return window.location.origin + cfg.callbackPath; }

    function clearTimer() { if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } }

    /** The kept refresh token as stored now ({rt, since}) or null. Re-read on
     *  every use: another tab may have rotated it since this tab loaded. */
    function storedKept() {
        var v = ls.getJSON(MD_AUTH_KEYS.rt);
        return (v && typeof v.rt === 'string' && v.rt && typeof v.since === 'number') ? v : null;
    }

    /**
     * Forget this tab's session. `onlyIfStoredRt` (Q-01 F3): when a refresh was
     * refused, delete the stored kept token ONLY if it is still the one this tab
     * sent - if another tab has rotated it since, that newer token belongs to a
     * live session and must survive. Sign-out passes nothing: it clears outright.
     */
    function clearLocal(reason, onlyIfStoredRt) {
        clearTimer();
        access = null; accessExp = 0; idClaims = null; refreshToken = null;
        keepThisSession = false; keepSince = 0;
        if (onlyIfStoredRt === undefined) {
            ls.del(MD_AUTH_KEYS.rt);
        } else {
            var cur = storedKept();
            if (!cur || cur.rt === onlyIfStoredRt) ls.del(MD_AUTH_KEYS.rt);
        }
        ss.del(MD_AUTH_KEYS.sso);
        lastError = reason || null;
    }

    function postForm(path, fields) {
        var body = Object.keys(fields).map(function (k) {
            return encodeURIComponent(k) + '=' + encodeURIComponent(fields[k]);
        }).join('&');
        return fetch(cfg.domain + path, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: body,
            credentials: 'omit',
            cache: 'no-store'
        });
    }

    function tokenRequest(fields) {
        return postForm('/oauth2/token', fields).then(function (res) {
            return res.json()['catch'](function () { return {}; }).then(function (json) {
                if (!res.ok) {
                    var err = new Error((json && json.error) || ('token_http_' + res.status));
                    err.status = res.status;
                    throw err;
                }
                return json;
            });
        });
    }

    function scheduleRefresh() {
        clearTimer();
        if (!refreshToken || !accessExp) return;
        var wait = Math.max(5000, accessExp - Date.now() - cfg.refreshSkewMs);
        refreshTimer = setTimeout(function () { refresh(); }, wait);
    }

    function persistRefresh() {
        if (!keepThisSession || !refreshToken) { ls.del(MD_AUTH_KEYS.rt); return; }
        if (Date.now() - keepSince >= cfg.keepMaxMs) { ls.del(MD_AUTH_KEYS.rt); return; }  // cap reached
        ls.setJSON(MD_AUTH_KEYS.rt, { rt: refreshToken, since: keepSince });
    }

    /** Take a token-endpoint response into memory. Returns an error reason or null. */
    function adopt(json, nonce) {
        if (!json || typeof json.access_token !== 'string') return 'no_access_token';
        var claims = idClaims;
        if (typeof json.id_token === 'string') {
            claims = mdAuthPkce.decodeJwt(json.id_token);
            var bad = mdAuthPkce.validateIdClaims(claims, {
                issuer: cfg.issuer, clientId: cfg.clientId, nonce: nonce || null, skewSec: cfg.clockSkewSec
            });
            if (bad) return bad;
        }
        if (!claims) return 'no_id_token';
        // Client-side expiry (Q-01 F7): expires_in counted from when the response
        // arrived, on THIS device's clock - immune to the device clock being off.
        // The token's own exp (server clock) is only the fallback.
        var at = mdAuthPkce.decodeJwt(json.access_token);
        var expiresIn = Number(json.expires_in);
        var expMs = (expiresIn > 0) ? Date.now() + expiresIn * 1000
            : (at && typeof at.exp === 'number') ? at.exp * 1000
            : Date.now() + 900 * 1000;
        access = json.access_token;
        accessExp = expMs;
        idClaims = claims;
        if (typeof json.refresh_token === 'string' && json.refresh_token) refreshToken = json.refresh_token; // rotation
        persistRefresh();
        scheduleRefresh();
        return null;
    }

    /**
     * Adopt a kept token another tab stored, when it belongs to THIS kept
     * session (same 7-day clock start - rotation keeps `since`). A different
     * `since` is a different sign-in, possibly another account: never adopted.
     * Returns true when the in-memory token changed.
     */
    function adoptStoredKept() {
        if (!keepThisSession) return false;
        var cur = storedKept();
        if (!cur || cur.since !== keepSince || cur.rt === refreshToken) return false;
        refreshToken = cur.rt;
        return true;
    }

    /**
     * Run fn while holding the cross-tab refresh lock (Q-01 F3). Web Locks make
     * the refresh single-flight across every tab of this origin, so two kept tabs
     * never present the same rotating token. Without navigator.locks (old Safari,
     * some embedded views) fn runs at once; the re-read + guarded clear in
     * refresh() still keep a rotated-away token from signing the other tab out.
     */
    function withRefreshLock(fn) {
        try {
            if (typeof navigator !== 'undefined' && navigator.locks && typeof navigator.locks.request === 'function') {
                return Promise.resolve(navigator.locks.request('md.auth.refresh', function () { return fn(); }));
            }
        } catch (e) { /* fall through */ }
        return fn();
    }

    /** Refresh with the refresh token. Resolves true/false; never rejects.
     *  A refused refresh (revoked, rotated away, expired) signs out cleanly -
     *  unless another tab rotated the kept token meanwhile, in which case this
     *  tab retries once with the newer token (Q-01 F3). */
    function refresh() {
        if (refreshing) return refreshing;
        if (!refreshToken) return Promise.resolve(false);

        function attempt(retried) {
            adoptStoredKept();                  // another tab may have rotated it already
            if (!refreshToken) return Promise.resolve(false);
            var sentToken = refreshToken;
            return tokenRequest({
                grant_type: 'refresh_token',
                client_id: cfg.clientId,
                refresh_token: sentToken
            }).then(function (json) {
                var bad = adopt(json, null);
                if (bad) throw Object.assign(new Error(bad), { status: 400 });
                if (status !== 'signedIn') setStatus('signedIn'); else emit();
                return true;
            })['catch'](function (err) {
                var refused = err && typeof err.status === 'number' && err.status >= 400 && err.status < 500;
                if (refused && !retried && adoptStoredKept()) return attempt(true);
                if (refused || Date.now() >= accessExp) {
                    clearLocal(refused ? 'session_ended' : 'refresh_unreachable', sentToken);
                    setStatus('signedOut');
                } else {
                    // Network blip with a still-valid access token: try again shortly.
                    clearTimer();
                    refreshTimer = setTimeout(function () { refresh(); }, 30000);
                }
                return false;
            });
        }

        refreshing = withRefreshLock(function () { return attempt(false); })
            ['catch'](function () { return false; })
            .then(function (ok) { refreshing = null; return ok; });
        return refreshing;
    }

    /* Another tab refreshed: follow the stored kept token (Q-01 F3). */
    try {
        window.addEventListener('storage', function (ev) {
            if (!ev || ev.key !== MD_AUTH_KEYS.rt || !keepThisSession) return;
            adoptStoredKept();
        });
    } catch (e) { /* no window events (non-browser host) */ }

    function finishCallback(cb) {
        var txn = cb.txn;
        if (!txn || !mdAuthPkce.stateMatches(txn.state, cb.state)) {
            lastError = 'state_mismatch';
            console.warn('[AuthService] Sign-in response did not match a sign-in started here; ignored.');
            return Promise.resolve(false);
        }
        if (cb.error) {
            lastError = cb.error;   // e.g. access_denied when the person backs out
            return Promise.resolve(false);
        }
        if (!txn.at || Date.now() - txn.at > cfg.txnMaxAgeMs || !mdAuthPkce.isValidVerifier(txn.verifier)) {
            lastError = 'stale_transaction';
            return Promise.resolve(false);
        }
        return tokenRequest({
            grant_type: 'authorization_code',
            client_id: cfg.clientId,
            code: cb.code,
            redirect_uri: redirectUri(),
            code_verifier: txn.verifier
        }).then(function (json) {
            keepThisSession = !!txn.keep;
            keepSince = Date.now();
            var bad = adopt(json, txn.nonce);
            if (bad) { clearLocal(bad); console.warn('[AuthService] Rejected tokens:', bad); return false; }
            ss.setJSON(MD_AUTH_KEYS.sso, { at: Date.now() });
            ss.del(MD_AUTH_KEYS.bounce);
            return true;
        })['catch'](function (err) {
            clearLocal('code_exchange_failed');
            console.warn('[AuthService] Code exchange failed:', err && err.message);
            return false;
        });
    }

    function restoreKept() {
        var stored = storedKept();
        if (!stored) { ls.del(MD_AUTH_KEYS.rt); return Promise.resolve(false); }
        if (Date.now() - stored.since >= cfg.keepMaxMs) { ls.del(MD_AUTH_KEYS.rt); return Promise.resolve(false); }
        refreshToken = stored.rt;
        keepThisSession = true;
        keepSince = stored.since;
        accessExp = 0;
        return refresh();
    }

    /** Within the managed-login cookie window, re-run authorize once; Cognito
     *  answers without a page. Returns true when the page is navigating away. */
    function trySilentBounce() {
        var marker = ss.getJSON(MD_AUTH_KEYS.sso);
        if (!marker || typeof marker.at !== 'number') return false;
        if (Date.now() - marker.at > cfg.ssoWindowMs) { ss.del(MD_AUTH_KEYS.sso); return false; }
        var last = Number(ss.get(MD_AUTH_KEYS.bounce)) || 0;
        if (Date.now() - last < 2 * 60 * 1000) return false;   // loop guard
        if (!window.crypto || !window.crypto.subtle) return false;
        ss.set(MD_AUTH_KEYS.bounce, String(Date.now()));
        // Q-01 F12: come back to the route the person reloaded on, not always '/'.
        startRedirect({ mode: 'signin', returnTo: mdAuthBoot.lastRoute(), keep: false });
        return true;
    }

    function startRedirect(opts) {
        if (!window.crypto || !window.crypto.subtle) {
            console.error('[AuthService] Sign-in needs a secure context (https or localhost).');
            return Promise.resolve(false);
        }
        var verifier = mdAuthPkce.createVerifier();
        var state = mdAuthPkce.createState();
        var nonce = mdAuthPkce.createState();
        return mdAuthPkce.challengeFor(verifier).then(function (challenge) {
            ss.setJSON(MD_AUTH_KEYS.txn, {
                state: state, verifier: verifier, nonce: nonce,
                returnTo: mdAuthPkce.safeReturnPath(opts.returnTo),
                keep: !!opts.keep, at: Date.now()
            });
            var url = mdAuthPkce.buildAuthorizeUrl(cfg, {
                mode: opts.mode, redirectUri: redirectUri(), state: state, nonce: nonce,
                challenge: challenge, identityProvider: opts.identityProvider
            });
            mdAuthNavigateTo(url);
            return true;
        });
    }

    /** Signed in: the access token, refreshed first when it is near expiry. */
    function tokenNow() {
        if (accessExp - Date.now() > 30000) return Promise.resolve(access);
        return refresh().then(function (ok) { return ok ? access : null; });
    }

    var engine = {
        provider: 'cognito',
        init: function () {
            if (initPromise) return initPromise;
            var cb = mdAuthBoot.callback;
            mdAuthBoot.callback = null;   // single use
            var start = cb ? finishCallback(cb) : restoreKept();
            initPromise = start.then(function (ok) {
                if (ok) { setStatus('signedIn'); return; }
                if (!cb && trySilentBounce()) return;   // stays 'loading' while the page leaves
                setStatus('signedOut');
            })['catch'](function () { clearLocal('init_failed'); setStatus('signedOut'); })
              .then(function () { initSettled = true; });
            // Back/forward cache (Q-01 F2): a page restored after a silent bounce
            // would sit in 'loading' forever. Nothing is navigating any more, so
            // it is signed out (sign-in is one tap away).
            try {
                window.addEventListener('pageshow', function (ev) {
                    if (ev && ev.persisted && initSettled && status === 'loading') setStatus('signedOut');
                });
            } catch (e) { /* ignore */ }
            return initPromise;
        },
        signIn: function (opts) {
            var o = opts || {};
            return startRedirect({
                mode: o.mode === 'signup' ? 'signup' : 'signin',
                returnTo: o.returnTo || mdAuthReturnPath.current,
                keep: engine.keepSignedIn(),
                identityProvider: o.identityProvider
            });
        },
        signOut: function () {
            var rt = refreshToken;
            clearLocal(null);
            ss.del(MD_AUTH_KEYS.bounce);
            setStatus('signedOut');
            var revoke = rt
                ? Promise.race([
                    postForm('/oauth2/revoke', { token: rt, client_id: cfg.clientId })['catch'](function () { /* best effort */ }),
                    new Promise(function (r) { setTimeout(r, 2000); })
                ])
                : Promise.resolve();
            return revoke.then(function () {
                mdAuthNavigateTo(cfg.domain + '/logout?client_id=' + encodeURIComponent(cfg.clientId) +
                    '&logout_uri=' + encodeURIComponent(window.location.origin + cfg.logoutPath));
            });
        },
        /**
         * Q-01 F2: while init is in flight, wait for it ONCE (bounded by
         * cfg.initWaitMs) and then answer from the settled state - never
         * recurse. After init has settled, a status still 'loading' means a
         * silent bounce is leaving the page (or failed to): resolve null at once.
         * The old `initPromise.then(getToken)` became an endless microtask chain
         * in that state and froze the tab.
         */
        getToken: function () {
            try {
                if (status === 'signedIn' && access) return tokenNow();
                if (status !== 'loading' || !initPromise || initSettled) return Promise.resolve(null);
                return new Promise(function (resolve) {
                    var done = false;
                    var timer = setTimeout(function () { if (!done) { done = true; resolve(null); } }, cfg.initWaitMs);
                    initPromise.then(function () {
                        if (done) return;
                        done = true; clearTimeout(timer);
                        resolve((status === 'signedIn' && access) ? tokenNow() : null);
                    }, function () { if (!done) { done = true; clearTimeout(timer); resolve(null); } });
                });
            } catch (e) {
                return Promise.resolve(null);
            }
        },
        getUser: function () { return status === 'signedIn' ? mdAuthUserFromClaims(idClaims) : null; },
        isSignedIn: function () { return status === 'signedIn'; },
        status: function () { return status; },
        lastError: function () { return lastError; },
        keepSignedIn: function () { return ls.get(MD_AUTH_KEYS.keep) === '1'; },
        setKeepSignedIn: function (on) {
            ls.set(MD_AUTH_KEYS.keep, on ? '1' : '0');
            // Unticking mid-session forgets the stored token at once; ticking only
            // applies from the next sign-in (the 7-day clock starts there).
            if (!on) { keepThisSession = false; ls.del(MD_AUTH_KEYS.rt); }
            emit();
        },
        onChange: function (cb) {
            listeners.push(cb);
            return function () { listeners = listeners.filter(function (x) { return x !== cb; }); };
        },
        /* introspection for the headless suite — no tokens, only shape */
        _debug: function () {
            return { status: status, hasAccess: !!access, hasRefresh: !!refreshToken, accessExp: accessExp,
                keepThisSession: keepThisSession, lastError: lastError, initSettled: initSettled };
        },
        /* test seam for the two-tab rotation checks (auth.js (o)) — resolves true/false */
        _forceRefresh: function () { return refresh(); }
    };
    return engine;
}

/* ------------------------------------------------------------------ */
/* Clerk engine (fallback) — bundles injected only when selected       */
/* ------------------------------------------------------------------ */

function mdAuthLoadScript(src, attrs) {
    return new Promise(function (resolve) {
        var s = document.createElement('script');
        s.src = src;
        s.async = false;               // preserve order: UI bundle before clerk-js
        s.crossOrigin = 'anonymous';
        Object.keys(attrs || {}).forEach(function (k) { s.setAttribute(k, attrs[k]); });
        s.onload = function () { resolve(true); };
        s.onerror = function () { resolve(false); };
        document.head.appendChild(s);
    });
}

function mdAuthCreateClerkEngine() {
    var cfg = mdAuthConfig();
    var status = 'loading';
    var listeners = [];
    var initPromise = null;

    function emit() { listeners.slice().forEach(function (cb) { try { cb(); } catch (e) { /* ignore */ } }); }
    function sync() {
        var c = window.Clerk;
        status = (c && c.session && c.user) ? 'signedIn' : 'signedOut';
        emit();
    }

    var engine = {
        provider: 'clerk',
        init: function () {
            if (initPromise) return initPromise;
            initPromise = Promise.all([
                mdAuthLoadScript(cfg.clerkHost + '/npm/@clerk/ui@1/dist/ui.browser.js'),
                mdAuthLoadScript(cfg.clerkHost + '/npm/@clerk/clerk-js@6/dist/clerk.browser.js',
                    { 'data-clerk-publishable-key': cfg.clerkPublishableKey })
            ]).then(function () {
                if (!window.Clerk) {
                    console.error('[AuthService] Clerk selected but window.Clerk did not load.');
                    status = 'signedOut'; emit();
                    return;
                }
                return Promise.resolve(window.Clerk.load({ ui: { ClerkUI: window.__internal_ClerkUICtor } }))
                    .then(function () {
                        sync();
                        if (typeof window.Clerk.addListener === 'function') window.Clerk.addListener(sync);
                    });
            })['catch'](function (err) {
                console.error('[AuthService] Clerk load failed:', err);
                status = 'signedOut'; emit();
            });
            return initPromise;
        },
        signIn: function (opts) {
            if (!window.Clerk) return Promise.resolve(false);
            if (opts && opts.mode === 'signup') window.Clerk.openSignUp({}); else window.Clerk.openSignIn({});
            return Promise.resolve(true);
        },
        signOut: function () {
            if (!window.Clerk) return Promise.resolve();
            return Promise.resolve(window.Clerk.signOut()).then(sync)['catch'](function (err) {
                console.error('[AuthService] Clerk signOut failed:', err);
            });
        },
        getToken: function () {
            try {
                if (window.Clerk && window.Clerk.session && typeof window.Clerk.session.getToken === 'function') {
                    return Promise.resolve(window.Clerk.session.getToken())['catch'](function () { return null; });
                }
            } catch (e) { /* fall through */ }
            return Promise.resolve(null);
        },
        getUser: function () { return (status === 'signedIn' && window.Clerk) ? mdAuthUserFromClerk(window.Clerk.user) : null; },
        isSignedIn: function () { return status === 'signedIn'; },
        status: function () { return status; },
        lastError: function () { return null; },
        keepSignedIn: function () { return false; },
        setKeepSignedIn: function () { /* Clerk manages its own session lifetime */ },
        onChange: function (cb) {
            listeners.push(cb);
            return function () { listeners = listeners.filter(function (x) { return x !== cb; }); };
        }
    };
    return engine;
}

/* ------------------------------------------------------------------ */
/* Facade — window.mdAuth                                              */
/* ------------------------------------------------------------------ */

/** Navigation seam (tests replace window.mdAuthNavigate to observe a redirect). */
var mdAuthNavigate = function (url) { window.location.assign(url); };
window.mdAuthNavigate = mdAuthNavigate;
function mdAuthNavigateTo(url) { (window.mdAuthNavigate || mdAuthNavigate)(url); }

/** The in-app route the person is on; RouteTracker (App.jsx) keeps it current so
 *  sign-in can return there. */
var mdAuthReturnPath = { current: '/' };

/** Which engine the flag selects. Unknown values fall back to cognito. */
function mdAuthSelectedProvider() {
    var v = (typeof FeatureFlags !== 'undefined' && FeatureFlags.getValue) ? FeatureFlags.getValue('AUTH_PROVIDER') : null;
    return v === 'clerk' ? 'clerk' : 'cognito';
}

var mdAuth = (function () {
    var engine = null;
    var listeners = [];   // the facade's own subscribers; they survive engine creation

    function emit() {
        listeners.slice().forEach(function (cb) { try { cb(); } catch (e) { /* ignore */ } });
    }

    function current() {
        if (engine) return engine;
        // Test seam: a suite may install a fake engine before boot.
        if (window.__MD_AUTH_TEST_ENGINE) {
            engine = window.__MD_AUTH_TEST_ENGINE;
        } else {
            engine = mdAuthSelectedProvider() === 'clerk' ? mdAuthCreateClerkEngine() : mdAuthCreateCognitoEngine();
        }
        engine.onChange(emit);
        return engine;
    }

    return {
        init: function () {
            try { return Promise.resolve(current().init())['catch'](function () { /* never rejects */ }); }
            catch (e) { return Promise.resolve(); }
        },
        provider: function () { return engine ? engine.provider : mdAuthSelectedProvider(); },
        signIn: function (opts) { return current().signIn(opts || {}); },
        signOut: function () { return current().signOut(); },
        getToken: function () {
            try {
                return engine ? Promise.resolve(engine.getToken())['catch'](function () { return null; }) : Promise.resolve(null);
            } catch (e) { return Promise.resolve(null); }
        },
        /** Plan §4 name for the same call (SyncService/MetricsService fallback). */
        getAccessToken: function () { return mdAuth.getToken(); },
        getUser: function () { return engine ? engine.getUser() : null; },
        isSignedIn: function () { return !!(engine && engine.isSignedIn()); },
        status: function () { return engine ? engine.status() : 'loading'; },
        lastError: function () { return engine && engine.lastError ? engine.lastError() : null; },
        keepSignedIn: function () { return current().keepSignedIn(); },
        setKeepSignedIn: function (on) { return current().setKeepSignedIn(!!on); },
        setReturnPath: function (p) {
            mdAuthReturnPath.current = mdAuthPkce.safeReturnPath(p);
            // Session-scoped, non-secret: lets a reload's silent bounce come back here.
            mdAuthStore('sessionStorage').set(MD_AUTH_KEYS.route, mdAuthReturnPath.current);
        },
        onChange: function (cb) {
            listeners.push(cb);
            return function () { listeners = listeners.filter(function (x) { return x !== cb; }); };
        },
        _engine: function () { return engine; }
    };
})();

/* ------------------------------------------------------------------ */
/* React                                                               */
/* ------------------------------------------------------------------ */

var _createContext_AU = React.createContext;
var _useState_AU      = React.useState;
var _useEffect_AU     = React.useEffect;
var _useContext_AU    = React.useContext;
var _useCallback_AU   = React.useCallback;

var MD_AUTH_SIGNED_OUT_VALUE = {
    isLoaded:   true,
    isSignedIn: false,
    user:       null,
    provider:   null,
    keepSignedIn: false,
    setKeepSignedIn: function () {},
    openSignIn: function () {},
    openSignUp: function () {},
    signOut:    function () {},
    getToken:   function () { return Promise.resolve(null); }
};

var AuthContext = _createContext_AU(MD_AUTH_SIGNED_OUT_VALUE);

/**
 * AuthProvider — gated by SUBSCRIPTIONS, as ClerkProvider was: flag off means no
 * engine, no network, signed out. While the engine is 'loading' (finishing a
 * callback, refreshing a kept session) isLoaded is false and surfaces show a
 * neutral state, never the signed-out one.
 */
var AuthProvider = function (props) {
    var subscriptionsEnabled = useFeatureFlag('SUBSCRIPTIONS');
    var tick = _useState_AU(0);
    var bump = tick[1];

    _useEffect_AU(function () {
        if (!subscriptionsEnabled) return undefined;
        var off = mdAuth.onChange(function () { bump(function (n) { return n + 1; }); });
        mdAuth.init().then(function () { bump(function (n) { return n + 1; }); });
        return off;
    }, [subscriptionsEnabled]);

    var openSignIn = _useCallback_AU(function () {
        if (subscriptionsEnabled) mdAuth.signIn({ mode: 'signin' });
    }, [subscriptionsEnabled]);
    var openSignUp = _useCallback_AU(function () {
        if (subscriptionsEnabled) mdAuth.signIn({ mode: 'signup' });
    }, [subscriptionsEnabled]);
    var signOut = _useCallback_AU(function () {
        if (subscriptionsEnabled) mdAuth.signOut();
    }, [subscriptionsEnabled]);
    var getToken = _useCallback_AU(function () {
        return subscriptionsEnabled ? mdAuth.getToken() : Promise.resolve(null);
    }, [subscriptionsEnabled]);
    var setKeepSignedIn = _useCallback_AU(function (on) {
        mdAuth.setKeepSignedIn(on);
    }, []);

    var value;
    if (!subscriptionsEnabled) {
        value = MD_AUTH_SIGNED_OUT_VALUE;
    } else {
        var st = mdAuth.status();
        value = {
            isLoaded:   st !== 'loading',
            isSignedIn: st === 'signedIn',
            user:       st === 'signedIn' ? mdAuth.getUser() : null,
            provider:   mdAuth.provider(),
            keepSignedIn: mdAuth.provider() === 'cognito' ? mdAuth.keepSignedIn() : false,
            setKeepSignedIn: setKeepSignedIn,
            openSignIn: openSignIn,
            openSignUp: openSignUp,
            signOut:    signOut,
            getToken:   getToken
        };
    }

    return (
        <AuthContext.Provider value={value}>
            {props.children}
        </AuthContext.Provider>
    );
};

var useAuth = function () {
    return _useContext_AU(AuthContext);
};

/**
 * AuthKeepSignedIn — the opt-in "Keep me signed in" control (plan D4). Two
 * looks: `variant="menu"` is a WAI-ARIA menuitemcheckbox row for HeaderMenu;
 * `variant="dark"` is a native checkbox for the dark promo panel (full accent
 * text on forest, DESIGN.md §2). Renders nothing unless Cognito is the engine.
 */
var AuthKeepSignedIn = function (props) {
    var auth = useAuth();
    if (auth.provider !== 'cognito' || auth.isSignedIn) return null;
    var on = !!auth.keepSignedIn;
    var toggle = function () { auth.setKeepSignedIn(!on); };
    var detail = 'On this device for up to 7 days. Leave off on a shared computer.';

    if (props.variant === 'menu') {
        return (
            <button
                type="button"
                role="menuitemcheckbox"
                aria-checked={on ? 'true' : 'false'}
                data-testid="menu-keep-signed-in"
                onClick={toggle}
                className="group w-full flex items-start gap-3 px-4 py-3 text-left btn-nature hover:bg-forest transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
            >
                <span aria-hidden="true" className={'mt-0.5 shrink-0 w-5 h-5 rounded-md flex items-center justify-center ring-2 transition-colors ' +
                    (on ? 'bg-forest ring-forest group-hover:bg-primary group-hover:ring-primary' : 'bg-surface ring-forest/40 group-hover:ring-white')}>
                    {on && (
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#ffffff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
                            <path d="M5 12.5l4.5 4.5L19 7.5" />
                        </svg>
                    )}
                </span>
                <span className="flex-1 min-w-0">
                    <span className="block text-sm font-bold text-forest group-hover:text-white transition-colors">Keep me signed in</span>
                    <span className="block text-[11px] font-semibold text-forest/80 group-hover:text-white transition-colors">{detail}</span>
                </span>
            </button>
        );
    }

    return (
        <label className="mt-3 flex items-start gap-3 cursor-pointer min-h-[44px] py-1" data-testid="home-keep-signed-in">
            <input
                type="checkbox"
                checked={on}
                onChange={toggle}
                className="mt-1 w-5 h-5 shrink-0 rounded accent-primary focus-visible:ring-2 focus-visible:ring-primary"
            />
            <span className="text-sm text-accent font-semibold leading-snug">
                Keep me signed in
                <span className="block text-xs font-medium text-accent">{detail}</span>
            </span>
        </label>
    );
};

window.mdAuthPkce = mdAuthPkce;
window.mdAuth = mdAuth;
window.mdAuthInitialPath = mdAuthInitialPath;
window.mdAuthUserFromClaims = mdAuthUserFromClaims;
window.AuthProvider = AuthProvider;
window.useAuth = useAuth;
window.AuthKeepSignedIn = AuthKeepSignedIn;
// One-release aliases (see header): other branches still name these.
window.ClerkProvider = AuthProvider;
window.useClerkAuth = useAuth;
