var _createContext_UP = React.createContext;
var _useState_UP      = React.useState;
var _useContext_UP    = React.useContext;
var _useCallback_UP   = React.useCallback;
var _useEffect_UP     = React.useEffect;
var _useRef_UP        = React.useRef;
var _useMemo_UP       = React.useMemo;

/**
 * UserProfileStore — the persisted, versioned user profile (onboarding answer,
 * recommended tier, active tier).
 *
 * Spec: architecture/ONBOARDING-PROFILE-MIGRATION-PLAN.md §5.
 *
 * Why a separate store and not a field on ScanStore (§5.1): the profile is
 * per-user with its own lifecycle — answered once, edited rarely, read on every
 * render that tailors UI. ScanStore is a per-scan collection; mixing them would
 * make every profile edit rewrite the whole scans array. Same StorageService,
 * own key (`mould-detect-user-profile`), own debounce.
 *
 * Mechanics mirror ScanStore deliberately (ScanStore.jsx:47-126): hydrate on
 * mount, 300 ms debounced persist, flush on pagehide/visibilitychange and on
 * provider unmount, and — the important one — persistence stays DISABLED for the
 * session if hydration failed, so a failed read can never overwrite a good
 * stored record with an empty default.
 *
 * Contract: nothing here throws. The profile is read on the path to the camera
 * and the data-moat rule (CLAUDE.md) is that nothing on that path may block or
 * fail a scan. A corrupt, absent or unreadable record degrades to the default
 * (unanswered) profile, and a hydration failure is additionally treated as
 * "already onboarded" for the session — a returning user being re-prompted is a
 * worse failure than a new user not being prompted once (§3).
 *
 * Record shape is §5.2 verbatim, JSON primitives only (string/number/boolean/
 * null, never undefined) so it rides the future opaque S3 cloud sync unchanged,
 * like captureContext and weatherSnapshot.
 *
 * Cloud mirror (§5.3, moved off Clerk by I-08): when a user is signed in,
 * every onboarding change is mirrored to the sync Lambda's `profile/current/`
 * object best-effort — no await, no state, errors swallowed — and once per
 * session an unanswered local record pulls the cloud copy (R3 decides). The
 * local store stays the single read path; the mirror only lets a profile
 * outlive a cleared browser and reach the Swift app through the same identity.
 *
 * Written in ES5 for Babel 6 compatibility. File-local helpers use the `ob`
 * (onboarding) namespace prefix — the app has no module system, so every
 * top-level var is a window global (CLAUDE.md hard rule).
 */

var UserProfileContext = _createContext_UP(null);

// Current record version. Bump only with a matching branch in obMigrateProfile.
var OB_PROFILE_VERSION = 1;

var OB_ROLE_DETAIL_MAX = 80;   // §5.2: roleDetail trimmed, max 80 chars.

// Allowed enum values. Anything else is coerced to null (allowlist discipline —
// the same rule as addScan: unknown values never reach storage).
var OB_COHORTS  = ['homeowner', 'business', 'professional', 'enterprise', 'unsure'];
var OB_CONTEXTS = ['my_home', 'client_property'];
var OB_ROLES    = ['cleaner', 'plumber', 'inspector', 'remediator', 'franchise',
                   'hygienist', 'lab', 'university', 'other'];
var OB_TIER_SOURCES = ['default', 'clerk', 'storekit', 'stripe'];

/** obOneOf — value if it is in the allowed list, else null. */
function obOneOf(value, allowed) {
    if (typeof value !== 'string') return null;
    for (var i = 0; i < allowed.length; i++) {
        if (allowed[i] === value) return value;
    }
    return null;
}

/** obStr — a trimmed string or null; never undefined, never a non-string. */
function obStr(value, maxLen) {
    if (typeof value !== 'string') return null;
    var trimmed = value.replace(/^\s+|\s+$/g, '');
    if (!trimmed) return null;
    return (maxLen && trimmed.length > maxLen) ? trimmed.slice(0, maxLen) : trimmed;
}

/** obNow — ISO-8601 timestamp, the format §5.2 specifies for the three dates. */
function obNow() {
    return new Date().toISOString();
}

/**
 * obNextStamp — `onboarding.updatedAt` for a write over a record stamped
 * `prevIso`: now, but never earlier than 1 ms after the stamp it replaces
 * (CLIENT-CONTRACT.md "Restore & conflict rules" R2: a slow device clock must
 * not make an edit made after a restore lose to the copy it restored).
 */
function obNextStamp(prevIso) {
    var now = Date.now();
    var prev = (typeof prevIso === 'string') ? Date.parse(prevIso) : NaN;
    return new Date((isFinite(prev) && prev >= now) ? prev + 1 : now).toISOString();
}

/** obDefaultProfile — the unanswered profile. Never shared by reference. */
function obDefaultProfile() {
    return {
        v: OB_PROFILE_VERSION,
        onboarding: {
            cohort: null,
            context: null,
            role: null,
            roleDetail: null,
            recommendedTier: 'free',
            source: null,
            completedAt: null,
            skippedAt: null,
            updatedAt: null
        },
        tierActive: 'free',
        tierSource: 'default'
    };
}

/**
 * obNormaliseProfile — build a record field by field from an arbitrary object.
 * This is the strict allowlist writer (§5.3): unknown fields are dropped,
 * `undefined` is coerced to `null`, enums are validated, and the result is always
 * a complete, JSON-primitive-only record at the current version.
 */
function obNormaliseProfile(raw) {
    var out = obDefaultProfile();
    if (!raw || typeof raw !== 'object') return out;

    var src = (raw.onboarding && typeof raw.onboarding === 'object') ? raw.onboarding : {};

    out.onboarding.cohort          = obOneOf(src.cohort, OB_COHORTS);
    out.onboarding.context         = obOneOf(src.context, OB_CONTEXTS);
    out.onboarding.role            = obOneOf(src.role, OB_ROLES);
    // Persisted verbatim (trimmed, capped) — §10 decision 1: "Something else" is
    // primarily a feedback capture, so the words the user typed are the asset.
    out.onboarding.roleDetail      = obStr(src.roleDetail, OB_ROLE_DETAIL_MAX);
    out.onboarding.recommendedTier = obOneOf(src.recommendedTier, Tiers.IDS) || 'free';
    out.onboarding.source          = obStr(src.source, 40);
    out.onboarding.completedAt     = obStr(src.completedAt, 40);
    out.onboarding.skippedAt       = obStr(src.skippedAt, 40);
    out.onboarding.updatedAt       = obStr(src.updatedAt, 40);

    // tierActive is what the user HAS (always 'free' until payments exist);
    // recommendedTier above is what onboarding SUGGESTS. Never conflate them:
    // display tailoring reads recommendedTier, entitlement reads tierActive.
    out.tierActive = obOneOf(raw.tierActive, Tiers.IDS) || 'free';
    out.tierSource = obOneOf(raw.tierSource, OB_TIER_SOURCES) || 'default';

    return out;
}

/**
 * obMigrateProfile — the migration hook (§5.3). Switch on the stored `v`;
 * today 1 → 1 is the identity normalisation. Absent, corrupt or future-versioned
 * records degrade to the default profile rather than throwing.
 */
function obMigrateProfile(raw) {
    if (!raw || typeof raw !== 'object') return obDefaultProfile();
    var v = (typeof raw.v === 'number') ? raw.v : 0;
    switch (v) {
        case 1:
            return obNormaliseProfile(raw);
        default:
            // v:0 (pre-versioned/corrupt) and anything newer than this build
            // understands: start clean rather than guess at a shape.
            return obDefaultProfile();
    }
}

/**
 * obMergeHydrated — reconcile a stored record that arrived LATE with an answer
 * the user gave while the read was still in flight.
 *
 * The race (review F1): hydration is async, the overlay can be open and answered
 * before `loadUserProfile()` resolves, and the naive resolve replaces state with
 * the stored record — silently discarding the answer that was just written, and
 * then persisting the discard.
 *
 * The merge rule, in one place so it can be tested without a browser:
 *   • `onboarding` — the IN-MEMORY block wins outright. It is the more recent
 *     thing the user did, and it is the only half of the record onboarding
 *     writes. A stored answer is by definition older than one typed since.
 *   • `tierActive`/`tierSource` — the STORED entitlement wins, unless the
 *     in-memory record carries a non-default one (a payment rail called
 *     setTierActive during the read). Entitlement is never authored by
 *     onboarding, so an in-memory 'free'/'default' here means "nothing was
 *     said", not "downgrade me".
 *
 * Always returns a complete, normalised record — both arguments go through the
 * same allowlist as everything else entering the store.
 */
function obMergeHydrated(stored, inMemory) {
    var storedRecord = obMigrateProfile(stored);
    var live = obNormaliseProfile(inMemory);
    var liveHasEntitlement = (live.tierSource !== 'default') || (live.tierActive !== 'free');
    return obNormaliseProfile({
        v: OB_PROFILE_VERSION,
        onboarding: live.onboarding,
        tierActive: liveHasEntitlement ? live.tierActive : storedRecord.tierActive,
        tierSource: liveHasEntitlement ? live.tierSource : storedRecord.tierSource
    });
}

/**
 * obIsOnboarded — has the user answered or deliberately skipped? Both count:
 * once either timestamp is set the overlay never re-prompts automatically (§3).
 */
function obIsOnboarded(profile) {
    if (!profile || !profile.onboarding) return false;
    return !!(profile.onboarding.completedAt || profile.onboarding.skippedAt);
}

/**
 * obProfileSnapshot — the per-scan `profileSnapshot` payload (§5.3), or null
 * when the user has neither answered nor skipped. Versioned, JSON primitives
 * only, same cloud-sync contract as weatherSnapshot/airSnapshot. Lives here, not
 * in AnalysisPage, so the record shape has exactly one author.
 *
 * A skipped onboarding DOES produce a snapshot (cohort 'unsure'): "I am just
 * exploring" is a real answer and the moat rule favours capturing what we know.
 * Only an unanswered profile — including a corrupt/unreadable one — yields null.
 */
function obProfileSnapshot(profile) {
    if (!obIsOnboarded(profile)) return null;
    var ob = profile.onboarding;
    return {
        v: 1,
        cohort: ob.cohort || null,
        role: ob.role || null,
        roleDetail: ob.roleDetail || null,
        recommendedTier: ob.recommendedTier || 'free',
        tierActive: profile.tierActive || 'free'
    };
}

/**
 * obMirrorToCloud — fire-and-forget mirror of the profile to the sync Lambda's
 * `profile/current/` object (I-08, Cognito plan §4; replaces the Clerk
 * `unsafeMetadata.mdProfile` mirror, which Cognito has no equivalent of). The
 * object is the same envelope Cloud backup writes, under the same R1–R10 rules
 * (SyncService.mirror). Works for both AUTH_PROVIDER values — the Lambda takes
 * Cognito and Clerk tokens. No await, no state, never throws: this sits on the
 * path to the camera and the moat rule says nothing here may block a scan.
 */
function obMirrorToCloud(auth, profile) {
    try {
        if (!auth || !auth.isSignedIn || !profile) return;
        if (typeof SyncService === 'undefined' || typeof SyncService.mirror !== 'function') return;
        SyncService.mirror('profile', profile, { getToken: auth.getToken });
    } catch (err) {
        console.warn('UserProfileStore: cloud profile mirror threw (ignored):', err);
    }
}

var UserProfileProvider = function (props) {
    var profileState = _useState_UP(obDefaultProfile);
    var profile = profileState[0];
    var setProfile = profileState[1];

    var hydratedState = _useState_UP(false);
    var hydrated = hydratedState[0];
    var setHydrated = hydratedState[1];

    // Hydration failed ⇒ persistence stays off AND the overlay is suppressed for
    // the session (§3). Kept separate from `hydrated` so the UI can still render.
    var failedState = _useState_UP(false);
    var hydrationFailed = failedState[0];
    var setHydrationFailed = failedState[1];

    // Header-menu re-entry ("Your setup") — a one-shot force-open flag. Not
    // persisted: it is a UI intent, not part of the profile record.
    var forceOpenState = _useState_UP(false);
    var forceOpen = forceOpenState[0];
    var setForceOpen = forceOpenState[1];

    // The signed-in user (AuthService), when SUBSCRIPTIONS is on. The provider
    // mounts INSIDE AuthProvider (App.jsx), and useAuth returns an inert
    // signed-out default otherwise, so this is always safe to call.
    var auth = useAuth();
    var authRef = _useRef_UP(auth);
    authRef.current = auth;

    // Gate for the debounced write, exactly as ScanStore does it: never persist
    // the initial default before the stored record has been read.
    var hydratedRef = _useRef_UP(false);

    // Second gate, which ScanStore does not need: this store's state changes on
    // hydration too (the read result replaces the default), and writing then
    // would stamp an empty record on every first visit — enough to make a
    // brand-new user look "seen" to anything that tests for a stored profile.
    // Only a real writer sets this.
    var dirtyRef = _useRef_UP(false);

    // --- Hydrate from IndexedDB on mount ---
    _useEffect_UP(function () {
        StorageService.loadUserProfile().then(function (raw) {
            // The user answered while this read was in flight (review F1): the
            // stored record is now the STALE one. Merge rather than clobber, and
            // mark dirty-hydrated so the debounce below persists the merge — the
            // answer was written to state, but never to storage, while
            // hydratedRef was still false.
            if (dirtyRef.current) {
                var merged = obMergeHydrated(raw, latestRef.current);
                latestRef.current = merged;
                hydratedRef.current = true;
                setProfile(merged);
                setHydrated(true);
                return;
            }
            // No stored record ⇒ leave the initial default in place rather than
            // replacing it with an identical object (which would also re-render).
            if (raw) setProfile(obMigrateProfile(raw));
            hydratedRef.current = true;
            setHydrated(true);
        })['catch'](function (err) {
            // Deliberately do NOT mark hydratedRef: persisting the default record
            // after a failed read would overwrite a perfectly good stored profile.
            // Writes stay disabled for this session; the data is intact next launch.
            console.error('UserProfileStore hydration failed — profile persistence disabled for this session:', err);
            setHydrationFailed(true);
            setHydrated(true);
        });
    }, []);

    // --- Debounced persistence (300 ms, same coalescing rule as ScanStore) ---
    var debounceRef = _useRef_UP(null);

    // Mirror of the latest record so the unload flush can write without being
    // part of the debounce effect's closure.
    var latestRef = _useRef_UP(profile);
    latestRef.current = profile;

    _useEffect_UP(function () {
        if (!hydratedRef.current || !dirtyRef.current) return;
        if (debounceRef.current) clearTimeout(debounceRef.current);
        debounceRef.current = setTimeout(function () {
            debounceRef.current = null;
            StorageService.saveUserProfile(profile)['catch'](function (err) {
                console.error('Failed to persist user profile:', err);
            });
        }, 300);
        return function () {
            if (debounceRef.current) clearTimeout(debounceRef.current);
        };
    }, [profile]);

    var flushPendingWrite = _useCallback_UP(function () {
        if (!debounceRef.current) return;
        clearTimeout(debounceRef.current);
        debounceRef.current = null;
        StorageService.saveUserProfile(latestRef.current)['catch'](function (err) {
            console.error('Failed to flush user profile on page hide:', err);
        });
    }, []);

    _useEffect_UP(function () {
        var onVisibilityChange = function () {
            if (document.visibilityState === 'hidden') flushPendingWrite();
        };
        window.addEventListener('pagehide', flushPendingWrite);
        document.addEventListener('visibilitychange', onVisibilityChange);
        return function () {
            window.removeEventListener('pagehide', flushPendingWrite);
            document.removeEventListener('visibilitychange', onVisibilityChange);
            flushPendingWrite(); // provider unmount
        };
    }, [flushPendingWrite]);

    /**
     * setOnboarding(patch) — the only writer for the onboarding block. Builds the
     * record field by field through obNormaliseProfile, so unknown keys are
     * dropped and undefined becomes null. `updatedAt` is stamped on every write.
     * Never throws; returns the normalised record it stored.
     */
    var setOnboarding = _useCallback_UP(function (patch) {
        // Built from latestRef (not a setState updater) so the normalised record
        // can be RETURNED synchronously to the caller — AnalysisPage needs the
        // answer immediately to render the result card and to snapshot a scan,
        // and a function updater does not run until the next render.
        var prev = latestRef.current || obDefaultProfile();
        var next = obNormaliseProfile({
            v: OB_PROFILE_VERSION,
            onboarding: Object.assign({}, prev.onboarding, patch || {}, { updatedAt: obNextStamp(prev.onboarding && prev.onboarding.updatedAt) }),
            tierActive: prev.tierActive,
            tierSource: prev.tierSource
        });
        latestRef.current = next;
        dirtyRef.current = true;
        setProfile(next);
        // Best-effort cloud mirror of every onboarding change (§5.3). Deliberately
        // after the local write and never awaited: IndexedDB is the read path, the
        // mirror is only a way for the profile to outlive this browser.
        obMirrorToCloud(authRef.current, next);
        return next;
    }, []);

    /**
     * completeOnboarding(answer) — the "Start scanning" write: cohort/context/
     * role/roleDetail plus the derived recommendation and a completedAt stamp.
     */
    var completeOnboarding = _useCallback_UP(function (answer) {
        answer = answer || {};
        var cohort = obOneOf(answer.cohort, OB_COHORTS);
        var role = obOneOf(answer.role, OB_ROLES);
        if (window.MetricsService) MetricsService.track('onboarding_completed', { cohort: cohort, role: role || 'none' }); // product metrics §2
        return setOnboarding({
            cohort: cohort,
            context: obOneOf(answer.context, OB_CONTEXTS),
            role: role,
            roleDetail: (role === 'other') ? obStr(answer.roleDetail, OB_ROLE_DETAIL_MAX) : null,
            recommendedTier: obRecommendTier(cohort, role),
            source: answer.source || 'onboarding-A',
            completedAt: obNow(),
            skippedAt: null
        });
    }, [setOnboarding]);

    /**
     * skipOnboarding — "Just exploring" (and Escape). Records the skip so we never
     * re-prompt, with cohort 'unsure' and the Free recommendation (§6).
     */
    var skipOnboarding = _useCallback_UP(function (source) {
        if (window.MetricsService) MetricsService.track('onboarding_skipped'); // product metrics §2
        return setOnboarding({
            cohort: 'unsure',
            context: null,
            role: null,
            roleDetail: null,
            recommendedTier: 'free',
            source: source || 'onboarding-A',
            completedAt: null,
            skippedAt: obNow()
        });
    }, [setOnboarding]);

    /**
     * Cloud adoption (§5.3, I-08) — once per signed-in session, after a
     * SUCCESSFUL hydration (a failed read must never be "fixed" from elsewhere):
     *
     *   • unanswered here ⇒ pull `profile/current/` through the importer, so a
     *     cleared browser, a second device or the Swift app's answer arrives.
     *     The importer applies R3 (strictly newer wins), so an answer given
     *     here while the pull is in flight is never overwritten (R7);
     *   • answered here ⇒ mirror it, so a first sign-in (or the Clerk → Cognito
     *     cutover's new account) gets a cloud copy without waiting for an edit.
     *     Skip-unchanged makes this free when the cloud already has it.
     */
    var cloudAdoptedRef = _useRef_UP(false);

    _useEffect_UP(function () {
        if (!hydrated || hydrationFailed || cloudAdoptedRef.current) return;
        if (!auth || !auth.isSignedIn) return;
        if (typeof SyncService === 'undefined' || typeof SyncService.pull !== 'function') return;
        cloudAdoptedRef.current = true;
        if (obIsOnboarded(latestRef.current)) obMirrorToCloud(auth, latestRef.current);
        else SyncService.pull('profile', { getToken: auth.getToken });
    }, [hydrated, hydrationFailed, auth]);

    /**
     * setTierActive — entitlement writer, for when payments land. `source` says
     * where the entitlement came from (clerk/storekit/stripe); today nothing
     * calls this and every user is 'free'/'default'.
     */
    var setTierActive = _useCallback_UP(function (tier, source) {
        if (window.MetricsService) MetricsService.track('subscription_changed', { tier: tier, source: source || 'default' }); // product metrics §2
        // Built from latestRef and written back to it synchronously, exactly as
        // setOnboarding does (review F4). A function updater alone left
        // latestRef holding the PRE-entitlement record until the next render, so
        // a same-tick setOnboarding — which builds from latestRef — would have
        // reverted tierActive/tierSource, and the unload flush would have
        // persisted the revert.
        var prev = latestRef.current || obDefaultProfile();
        var next = obNormaliseProfile({
            v: OB_PROFILE_VERSION,
            onboarding: Object.assign({}, prev.onboarding, { updatedAt: obNextStamp(prev.onboarding && prev.onboarding.updatedAt) }),
            tierActive: tier,
            tierSource: source || 'default'
        });
        latestRef.current = next;
        dirtyRef.current = true;
        setProfile(next);
        return next;
    }, []);

    /** openOnboarding / closeOnboarding — header-menu re-entry (§2 item 5). */
    var openOnboarding = _useCallback_UP(function () { setForceOpen(true); }, []);
    var closeOnboarding = _useCallback_UP(function () { setForceOpen(false); }, []);

    /**
     * importProfile(record) — the ONLY way a cloud restore reaches this store
     * (review F1). The profile is a single record, so the merge is the service's
     * own rule applied once: the remote replaces the local only when its
     * `onboarding.updatedAt` is STRICTLY newer. A tie keeps what is on screen.
     *
     * Migrated on the way in (`obMigrateProfile`) like the hydration path, and
     * committed through `latestRef` + `dirtyRef` so the ordinary debounce
     * persists it. Returns `{ added, replaced, kept }`, or null when hydration
     * failed and this store has no idea what it is merging against.
     */
    var importProfile = _useCallback_UP(function (record) {
        if (!hydratedRef.current) return null;
        if (!record || typeof record !== 'object') return { added: 0, replaced: 0, kept: 0 };
        var next = obMigrateProfile(record);
        var prev = latestRef.current || null;
        if (!window.syRemoteWins('profile', prev, next)) return { added: 0, replaced: 0, kept: 1 };
        latestRef.current = next;
        dirtyRef.current = true;
        setProfile(next);
        return { added: prev ? 0 : 1, replaced: prev ? 1 : 0, kept: 0 };
    }, []);

    _useEffect_UP(function () {
        if (typeof SyncService === 'undefined' || !SyncService.registerImporter) return;
        return SyncService.registerImporter('profile', importProfile);
    }, [importProfile]);

    var contextValue = _useMemo_UP(function () {
        var onboarding = profile.onboarding;
        return {
            // --- state ---
            profile: profile,
            onboarding: onboarding,
            hydrated: hydrated,
            hydrationFailed: hydrationFailed,
            recommendedTier: onboarding.recommendedTier || 'free',
            tierActive: profile.tierActive || 'free',
            isSubscribed: obIsSubscribed(profile),
            effectiveTier: obEffectiveTier(profile),
            isOnboarded: obIsOnboarded(profile),
            forceOpen: forceOpen,
            /**
             * shouldPrompt — the §3 gate, computed in one place: hydrated, the
             * read succeeded, and the user has neither answered nor skipped.
             * `forceOpen` (the header-menu row / dashboard chip) bypasses the
             * answered check but NOT the hydration one (review F1): opening the
             * sheet over an unread profile shows a blank question to a user who
             * has already answered, and any edit made there races the read.
             */
            shouldPrompt: hydrated && (forceOpen || (!hydrationFailed && !obIsOnboarded(profile))),
            // --- writers ---
            setOnboarding: setOnboarding,
            completeOnboarding: completeOnboarding,
            skipOnboarding: skipOnboarding,
            setTierActive: setTierActive,
            openOnboarding: openOnboarding,
            closeOnboarding: closeOnboarding,
            // --- slice 6: cloud restore ---
            importProfile: importProfile,
            // --- derived ---
            getProfileSnapshot: function () { return obProfileSnapshot(profile); }
        };
    }, [profile, hydrated, hydrationFailed, forceOpen, setOnboarding, completeOnboarding,
        skipOnboarding, setTierActive, openOnboarding, closeOnboarding, importProfile]);

    return React.createElement(UserProfileContext.Provider, { value: contextValue }, props.children);
};

/**
 * useUserProfile — context hook. Unlike useScanStore this does NOT throw when
 * the provider is missing: it returns a safe, inert default. A component tree
 * mounted without the provider (a test harness, a future embed) must degrade to
 * "no profile, no prompt", never to a crashed scan flow.
 */
var useUserProfile = function () {
    var ctx = _useContext_UP(UserProfileContext);
    if (ctx) return ctx;
    var fallback = obDefaultProfile();
    return {
        profile: fallback,
        onboarding: fallback.onboarding,
        hydrated: false,
        hydrationFailed: true,
        recommendedTier: 'free',
        tierActive: 'free',
        isSubscribed: false,
        effectiveTier: 'free',
        isOnboarded: false,
        forceOpen: false,
        shouldPrompt: false,
        setOnboarding: function () { return null; },
        completeOnboarding: function () { return null; },
        skipOnboarding: function () { return null; },
        setTierActive: function () {},
        openOnboarding: function () {},
        closeOnboarding: function () {},
        getProfileSnapshot: function () { return null; },
        importProfile: function () { return null; }
    };
};

/**
 * useCohortDefaults — display tailoring, NOT gating (§7).
 *
 * Returns emphasis hints derived from the recommended tier's rank. Nothing a
 * Free user is entitled to is hidden by this: homeowners get the simple
 * ordering (scan → verdict → next steps) with the professional cards collapsed
 * into one row, pro cohorts get them expanded. Entitlement enforcement arrives
 * with payments, reading `tierActive` and the Tiers matrix — that is the seam.
 *
 * Consumed by HomePage: with `emphasis === 'simple'` the reports (Severity
 * Trend) and environment (Air Quality) cards fold into one "More for
 * professionals" row that expands in place; with 'pro' they render expanded as
 * before. `showManagement` got its surface in ADR-009 slice 2: the dashboard's
 * **Assessments** card, which renders behind the MANAGE_MOULD flag and uses
 * this hint to decide WHERE it sits (expanded in the pro area, or folded into
 * that row) — never whether the feature exists.
 */
/**
 * obPreviewTier — the `ENTITLEMENTS_PREVIEW_TIER` dev override (slice 5).
 *
 * Payments do not exist, so every user is Free and NO tier gate can be
 * exercised on dev. This flag is the one way to look at the app as a Business
 * or Professional customer: set it to a tier id and every seam below reports
 * that tier's matrix. Returns the tier id, or **null** when the flag is off,
 * absent or set to something that is not one of the five tiers — an unknown
 * value must never silently entitle anybody.
 *
 * Read at CALL time through FeatureFlags (the same synchronous in-memory cache
 * `useFeatureFlag` reads), so this stays a plain function the non-React callers
 * below can use, and so there is exactly one place the override is decided.
 *
 * It is READ-ONLY by construction: nothing here touches `tierActive`, so a
 * preview session can never leave something behind that looks like a real
 * entitlement.
 */
var OB_PREVIEW_TIER_FLAG = 'ENTITLEMENTS_PREVIEW_TIER';

function obPreviewTier() {
    if (typeof FeatureFlags === 'undefined' || !FeatureFlags || typeof FeatureFlags.getValue !== 'function') return null;
    // Two ways to be off, and either one wins: the flag switched off entirely,
    // or (the committed state) its value left at "off". A dev override should
    // be hard to leave on by accident and easy to kill.
    if (typeof FeatureFlags.isEnabled === 'function' && !FeatureFlags.isEnabled(OB_PREVIEW_TIER_FLAG)) return null;
    var value = FeatureFlags.getValue(OB_PREVIEW_TIER_FLAG);
    if (typeof value !== 'string' || !value || value === 'off') return null;
    // An unknown value must never silently entitle anybody.
    return Tiers.TIERS[value] ? value : null;
}

/**
 * obIsSubscribed — the precedence rule (Rozario, 2026-09-19; ADR-007 D2):
 * a subscription, once present, is the CANONICAL setting. `tierActive` above
 * 'free' means one exists (it is only ever written by setTierActive with a
 * payment-rail source). Onboarding never touches it.
 *
 * The tier preview counts as subscribed (slice 5) — a previewed Professional
 * who still got the homeowner dashboard would be testing half a tier.
 */
function obIsSubscribed(profile) {
    if (obPreviewTier()) return true;
    return Tiers.rank(profile && profile.tierActive) > Tiers.rank('free');
}

/**
 * obEffectiveTier — the tier display tailoring reads:
 *   subscribed   → tierActive (canonical; the pricing matrix drives features)
 *   unsubscribed → onboarding.recommendedTier (the user-type setting; decides
 *                  what a Free user is shown, never what they are entitled to)
 */
function obEffectiveTier(profile) {
    // The dev preview wins over everything: it is the tier the operator asked
    // to be shown as, and `tierActive` is untouched underneath it (slice 5).
    var preview = obPreviewTier();
    if (preview) return preview;
    if (obIsSubscribed(profile)) return profile.tierActive;
    return (profile && profile.onboarding && profile.onboarding.recommendedTier) || 'free';
}

var useCohortDefaults = function () {
    var store = useUserProfile();
    var tierId = store.effectiveTier;
    var tier = Tiers.get(tierId);
    var isPro = Tiers.rank(tierId) >= Tiers.rank('business');
    // `hydrated` is carried out with the emphasis hints (review F3) so a
    // consumer can render NOTHING for the ~ms the stored profile takes to read,
    // instead of laying out the unanswered default and re-laying it out a frame
    // later. Callers that do not care may ignore it: the hints below are the
    // same safe default they always were.
    if (store.isSubscribed) {
        // Canonical: the feature matrix of the PAID tier decides what shows.
        // Essential = saved reports but no Miss Mould/team; Business+ = the pro
        // dashboard. Environment/management follow the pro rank (Essential's
        // environment access is capped like Free's, per the matrix).
        return {
            showReports: !!tier.reports,
            showManagement: isPro,
            showEnvironment: isPro,
            showMissMould: !!tier.missMould || isPro,
            emphasis: isPro ? 'pro' : 'simple',
            source: 'subscription',
            hydrated: store.hydrated
        };
    }
    // Unsubscribed: the user-type setting from onboarding (via its recommended
    // tier) decides emphasis. Nothing here grants a capability.
    return {
        showReports: isPro,
        showManagement: isPro,
        showEnvironment: isPro,
        showMissMould: isPro,
        emphasis: isPro ? 'pro' : 'simple',
        source: 'onboarding',
        hydrated: store.hydrated
    };
};

/**
 * useEntitlements — the enforcement seam. Reads ONLY the canonical tier
 * (`tierActive`) and the Tiers matrix; never onboarding. Today every user is
 * 'free'. When a payment rail calls setTierActive, gates that read this hook
 * start honouring the subscription with no other change.
 */
var useEntitlements = function () {
    var store = useUserProfile();
    // Slice 5: `ENTITLEMENTS_PREVIEW_TIER`, off in the committed file, is the
    // only thing that can stand in front of the stored tier — and it reports
    // itself through `isPreview` so a surface can say so rather than pretend.
    var preview = obPreviewTier();
    var tier = Tiers.get(preview || store.tierActive);
    return {
        tier: tier.id,
        isPreview: !!preview,
        // `|| preview` rather than trusting the store: obIsSubscribed already
        // honours the flag, but a tree mounted WITHOUT the provider gets the
        // inert fallback's hard `false`, and a preview that entitles the matrix
        // while reporting "not subscribed" is a half-answer.
        isSubscribed: store.isSubscribed || !!preview,
        reports: !!tier.reports,
        savedHistory: !!tier.savedHistory,
        whiteLabelPdf: !!tier.whiteLabelPdf,
        csvExport: !!tier.csvExport,
        missMould: !!tier.missMould,
        team: !!tier.team,
        api: !!tier.api,
        scanCap: tier.scanCap,
        propertyCap: tier.propertyCap
    };
};

window.UserProfileProvider = UserProfileProvider;
window.useUserProfile = useUserProfile;
window.useCohortDefaults = useCohortDefaults;
window.useEntitlements = useEntitlements;
window.obIsSubscribed = obIsSubscribed;
window.obPreviewTier = obPreviewTier;
window.obEffectiveTier = obEffectiveTier;
window.obProfileSnapshot = obProfileSnapshot;
window.obMigrateProfile = obMigrateProfile;
window.obMergeHydrated = obMergeHydrated;
window.obNormaliseProfile = obNormaliseProfile;
window.OB_ROLE_DETAIL_MAX = OB_ROLE_DETAIL_MAX;
