/**
 * MetricsService — the product-metrics client
 * (architecture/PRODUCT-METRICS-AND-PERSISTENCE-PLAN.md §2).
 *
 * NOT analytics. `components/AnalyticsService` is GA4 — customer-experience
 * and web analytics, a different system with a different owner, and nothing
 * here reuses it. This is the product/system layer behind the login-gated
 * dashboard: how many accounts, how many scans, how far into the funnel, how
 * much is backed up. It answers "is the product being used", never "what did
 * this person do".
 *
 * THE RULE, and it is the whole design:
 *
 *   **Count-only.** Every prop is an enum, an integer or a boolean drawn from
 *   `event-schema.json` — the SAME bytes the Lambda validates against
 *   (`mould-detect-sync-lambda/src/event-schema.json`; a test in each place
 *   asserts the two files are identical). No image, no address, no room name,
 *   no note, no lab text, no file name ever enters an event. A prop that is
 *   not in the allowlist is stripped here and rejected there.
 *
 * Identity is two ids and nothing else: the signed-in `sub` when signed in (the
 * SERVER reads it from the JWT — this client never sends a user id), and a
 * random per-browser `visitorId` so a signed-out session is still countable.
 *
 * Contracts this file keeps:
 *
 *   • **Never throws, never blocks.** `track()` is fire-and-forget and returns
 *     nothing. Every path is wrapped. A scan save must never be delayed, let
 *     alone failed, by measurement (CLAUDE.md, the data-moat rule) — that rule
 *     is why capture is unconditional and why THIS file must be inert under
 *     every failure it can meet.
 *   • **Three off switches, any one of which stops every request:** the
 *     `PRODUCT_METRICS` flag, an empty `syncEndpoint` in
 *     `components/SyncService/sync-config.json` (the stack is not deployed —
 *     so the committed state of this repo sends nothing at all), and
 *     `profile.metricsOptOut === true` on the stored user profile.
 *   • **The queue is bounded.** At most 500 events in IndexedDB; the oldest
 *     are dropped first. Measurement may never become the reason a browser
 *     runs out of storage for scans.
 *
 * File-local helpers are `mx`-prefixed — there is no module system here and
 * every top-level `var` is a window global (CLAUDE.md hard rule).
 */

/* eslint-disable no-var */

var MX_SCHEMA_URL  = 'components/MetricsService/event-schema.json';
var MX_CONFIG_URL  = 'components/SyncService/sync-config.json';
var MX_FLAG        = 'PRODUCT_METRICS';

/** IndexedDB keys — the same localforage database StorageService uses, so the
 *  metrics queue is cleared by the same "clear site data" the rest is. */
var MX_VISITOR_KEY = 'mould-detect-metrics-visitor';
var MX_QUEUE_KEY   = 'mould-detect-metrics-queue';
var MX_PROFILE_KEY = 'mould-detect-user-profile';

var MX_MAX_QUEUE   = 500;    // events held locally; oldest dropped first
var MX_MAX_BATCH   = 50;     // the Lambda's MAX_BATCH — a bigger batch is a 400
var MX_FLUSH_MS    = 10000;  // the background flush interval
var MX_CONTEXT_TTL = 30000;  // how often the stored profile is re-read
var MX_PERSIST_MS  = 800;    // debounce before the queue is written to IndexedDB

/* ------------------------------------------------------------------ *
 * Pure helpers — no state, no network; exported for the headless suite
 * ------------------------------------------------------------------ */

/** mxUuid — a v4 UUID. The Lambda rejects a visitorId that is not one. */
function mxUuid() {
    try {
        if (window.crypto && typeof window.crypto.randomUUID === 'function') {
            return window.crypto.randomUUID();
        }
        if (window.crypto && window.crypto.getRandomValues) {
            var bytes = new Uint8Array(16);
            window.crypto.getRandomValues(bytes);
            bytes[6] = (bytes[6] & 0x0f) | 0x40;
            bytes[8] = (bytes[8] & 0x3f) | 0x80;
            var hex = [];
            for (var i = 0; i < 16; i++) hex.push((bytes[i] + 0x100).toString(16).slice(1));
            return hex.slice(0, 4).join('') + '-' + hex.slice(4, 6).join('') + '-' +
                   hex.slice(6, 8).join('') + '-' + hex.slice(8, 10).join('') + '-' +
                   hex.slice(10, 16).join('');
        }
    } catch (err) { /* fall through */ }
    // Last resort. Still UUID-SHAPED, because the server's regex is the gate.
    var chars = '0123456789abcdef';
    var out = '';
    for (var c = 0; c < 36; c++) {
        if (c === 8 || c === 13 || c === 18 || c === 23) { out += '-'; continue; }
        if (c === 14) { out += '4'; continue; }
        out += chars[Math.floor(Math.random() * 16)];
    }
    return out;
}

/**
 * mxCleanProps — the client half of the allowlist.
 *
 * Unlike the Lambda, which REJECTS an invalid batch outright, this strips:
 * a typo in a call site must cost the offending prop, never the event and
 * never a 400 the flush would retry forever. The server is the real gate;
 * this is what keeps a 400 from ever happening.
 *
 * @returns {object|null} cleaned props, or null when the NAME is unknown
 */
function mxCleanProps(schema, name, props) {
    if (!schema || !schema.events || !Object.prototype.hasOwnProperty.call(schema.events, name)) return null;
    var allowed = schema.events[name];
    var source = (props && typeof props === 'object' && !Array.isArray(props)) ? props : {};
    var out = {};
    var maxString = schema.maxStringLength || 40;

    for (var key in allowed) {
        if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue;
        var spec = allowed[key];
        var value = source[key];
        if (value === undefined || value === null) continue;

        if (spec.type === 'bool') {
            if (typeof value === 'boolean') out[key] = value;
        } else if (spec.type === 'int') {
            if (typeof value === 'number' && isFinite(value) && Math.floor(value) === value &&
                value >= spec.min && value <= spec.max) {
                out[key] = value;
            }
        } else if (spec.type === 'enum') {
            if (typeof value === 'string' && value.length <= maxString && spec.values.indexOf(value) !== -1) {
                out[key] = value;
            }
        }
    }
    return out;
}

/** mxEnvelopeValue — tier/cohort against the schema's envelope lists. */
function mxEnvelopeValue(schema, field, value) {
    if (!schema || !schema.envelope || !schema.envelope[field]) return null;
    if (typeof value !== 'string' || !value) return null;
    return schema.envelope[field].indexOf(value) === -1 ? null : value;
}

/**
 * mxAssessmentCounts — the three numbers `assessment_completed` carries.
 *
 * It lives HERE rather than in AssessmentStore so that store's call site is
 * one line and the `as` prefix stays that file's own. Built from
 * `asDeriveSummary` (the store's derived summary — rooms, scored components,
 * pending samples), so the event and the visit screen can never disagree about
 * how big a visit was. Counts only: not a room name, not a material, not a
 * note. Returns zeros for anything unreadable — a missing number is never
 * invented, but it must not cost the event either.
 */
function mxAssessmentCounts(assessment) {
    var out = { rooms: 0, componentsScored: 0, samples: 0 };
    try {
        var summary = (typeof asDeriveSummary === 'function') ? asDeriveSummary(assessment) : null;
        if (summary) {
            out.rooms = summary.roomCount || 0;
            out.componentsScored = summary.componentsScored || 0;
        } else if (assessment && assessment.rooms && assessment.rooms.length) {
            out.rooms = assessment.rooms.length;
        }
        if (assessment && assessment.samples && assessment.samples.length) out.samples = assessment.samples.length;
    } catch (err) { /* counts stay zero */ }
    return out;
}

/** mxTrim — the queue cap, oldest first. */
function mxTrim(queue, max) {
    var limit = max || MX_MAX_QUEUE;
    return queue.length <= limit ? queue : queue.slice(queue.length - limit);
}

/* ------------------------------------------------------------------ *
 * The service
 * ------------------------------------------------------------------ */

var MetricsService = (function () {

    // The same localforage database/store StorageService opens. Its own
    // instance rather than new StorageService methods: the metrics queue is
    // not app data, nothing else reads it, and it must never be caught by the
// (own localforage instance so the metrics queue never contends with the record stores)
    var _store = null;
    function store() {
        if (!_store && typeof localforage !== 'undefined') {
            _store = localforage.createInstance({
                name: 'MouldDetect',
                storeName: 'app_data',
                description: 'MouldGuard AI application data'
            });
        }
        return _store;
    }

    var _schema = null;
    var _endpoint = '';        // '' ⇒ inert
    var _visitorId = null;
    var _queue = [];           // { name, at, props, tier, cohort }
    var _ready = null;         // the init promise
    var _enabled = false;      // the flag, resolved once at init
    var _optOut = false;
    var _context = { tier: null, cohort: null, readAt: 0 };
    var _token = null;         // last access token seen; used by the keepalive flush
    var _flushing = false;
    var _timer = null;
    var _persistTimer = null;
    var _sent = 0;             // batches accepted — the suite asserts on it

    /* --- storage ---------------------------------------------------- */

    function loadVisitorId() {
        var s = store();
        if (!s) return Promise.resolve(mxUuid());
        return s.getItem(MX_VISITOR_KEY).then(function (stored) {
            if (typeof stored === 'string' && stored.length === 36) return stored;
            var fresh = mxUuid();
            return s.setItem(MX_VISITOR_KEY, fresh).then(function () { return fresh; })
                ['catch'](function () { return fresh; });
        })['catch'](function () { return mxUuid(); });
    }

    function loadQueue() {
        var s = store();
        if (!s) return Promise.resolve([]);
        return s.getItem(MX_QUEUE_KEY).then(function (raw) {
            return Array.isArray(raw) ? mxTrim(raw, MX_MAX_QUEUE) : [];
        })['catch'](function () { return []; });
    }

    /** persist — debounced, best-effort. A failed write costs measurement, nothing else. */
    function persist() {
        if (_persistTimer) clearTimeout(_persistTimer);
        _persistTimer = setTimeout(function () {
            _persistTimer = null;
            persistNow();
        }, MX_PERSIST_MS);
    }

    function persistNow() {
        var s = store();
        if (!s) return Promise.resolve();
        return s.setItem(MX_QUEUE_KEY, _queue)['catch'](function () { return null; });
    }

    /* --- context ---------------------------------------------------- */

    /**
     * refreshContext — tier, cohort and the opt-out, read from the stored
     * profile rather than from React. This service has no component to hang
     * off, and the profile record is the same one `obProfileSnapshot` reads.
     */
    function refreshContext(force) {
        var now = Date.now();
        if (!force && (now - _context.readAt) < MX_CONTEXT_TTL) return Promise.resolve(_context);
        var s = store();
        if (!s) return Promise.resolve(_context);
        return s.getItem(MX_PROFILE_KEY).then(function (profile) {
            _context.readAt = Date.now();
            if (!profile || typeof profile !== 'object') {
                _context.tier = null;
                _context.cohort = null;
                return _context;
            }
            // Opt-out: honoured if the field is present, ignored if it is not.
            // No UI writes it yet; the Privacy page describes it, and when the
            // toggle ships it writes this one boolean.
            _optOut = profile.metricsOptOut === true;
            var onboarding = profile.onboarding || {};
            var tier = (typeof obEffectiveTier === 'function') ? obEffectiveTier(profile) : profile.tierActive;
            _context.tier = mxEnvelopeValue(_schema, 'tier', tier);
            _context.cohort = mxEnvelopeValue(_schema, 'cohort', onboarding.cohort);
            return _context;
        })['catch'](function () { return _context; });
    }

    /* --- init ------------------------------------------------------- */

    /**
     * init — load the schema and the endpoint, adopt a visitor id, restore the
     * queue, start the timer and the lifecycle listeners. Never rejects: any
     * failure leaves the service inert, which is the safe state.
     *
     * Called lazily by track(), so a call site needs ONE guarded line and no
     * boot-order knowledge.
     */
    function init() {
        if (_ready) return _ready;
        _ready = Promise.resolve()
            .then(function () {
                _enabled = (typeof FeatureFlags !== 'undefined') && FeatureFlags.isEnabled(MX_FLAG);
                if (!_enabled) return null;
                return Promise.all([
                    fetch(MX_SCHEMA_URL).then(function (r) { return r.ok ? r.json() : null; })['catch'](function () { return null; }),
                    fetch(MX_CONFIG_URL).then(function (r) { return r.ok ? r.json() : null; })['catch'](function () { return null; }),
                    loadVisitorId(),
                    loadQueue()
                ]).then(function (parts) {
                    _schema = parts[0];
                    var config = parts[1] || {};
                    var url = (typeof config.syncEndpoint === 'string') ? config.syncEndpoint.trim() : '';
                    _endpoint = url ? (url.replace(/\/+$/, '') + '/events') : '';
                    _visitorId = parts[2];
                    // Stored queue FIRST, then whatever was tracked while this
                    // promise was in flight. `track()` runs init() and pushes
                    // in the same tick, so assigning the stored queue over the
                    // top would silently swallow the very first event of every
                    // session — `app_open`, tracked in the boot chain.
                    _queue = mxTrim((parts[3] || []).concat(_queue), MX_MAX_QUEUE);
                    if (!_schema) _endpoint = '';  // no allowlist ⇒ nothing may be sent
                    startTimers();
                    return refreshContext(true);
                });
            })
            ['catch'](function (err) {
                console.warn('[MetricsService] init failed, metrics stay off:', err && err.message);
                _endpoint = '';
                return null;
            });
        return _ready;
    }

    function startTimers() {
        if (_timer || !_endpoint) return;
        _timer = setInterval(function () { flush({ reason: 'timer' }); }, MX_FLUSH_MS);

        // pagehide is the one lifecycle event both Safari and Chrome fire on
        // a real navigation away; visibilitychange catches the tab being
        // backgrounded on mobile, which is where a session usually ends.
        window.addEventListener('pagehide', function () { flush({ keepalive: true, reason: 'pagehide' }); });
        document.addEventListener('visibilitychange', function () {
            if (document.visibilityState === 'hidden') flush({ keepalive: true, reason: 'hidden' });
        });
        // Back online: the queue is exactly what could not be sent.
        window.addEventListener('online', function () { flush({ reason: 'online' }); });
    }

    /* --- track ------------------------------------------------------ */

    /**
     * track(name, props) — queue one count-only event. Returns nothing and
     * never throws; call it inline at the point the thing happened.
     */
    function track(name, props) {
        try {
            init();
            if (_optOut) return;
            var at = new Date().toISOString();
            // The schema may still be loading on the very first call (app_open
            // is tracked in the boot chain). Queue the raw shape and clean it
            // at flush time — the allowlist is applied either way, and an
            // event is never lost to a race with its own configuration.
            _queue.push({ name: name, at: at, props: props || null, tier: null, cohort: null, clean: false });
            _queue = mxTrim(_queue, MX_MAX_QUEUE);
            stampContext();
            persist();
        } catch (err) {
            // Deliberately swallowed: measurement never surfaces to the user
            // and never interrupts the thing being measured.
        }
    }

    /** stampContext — attach tier/cohort to anything queued without them. */
    function stampContext() {
        refreshContext(false).then(function (context) {
            for (var i = 0; i < _queue.length; i++) {
                if (_queue[i].tier === null) _queue[i].tier = context.tier;
                if (_queue[i].cohort === null) _queue[i].cohort = context.cohort;
            }
        })['catch'](function () { return null; });
    }

    /* --- flush ------------------------------------------------------ */

    /** mxToWire — one queued entry as the Lambda's event shape, or null. */
    function toWire(entry) {
        var props = mxCleanProps(_schema, entry.name, entry.props);
        if (props === null) {
            console.warn('[MetricsService] unknown event name dropped:', entry.name);
            return null;
        }
        var wire = { name: entry.name, at: entry.at, visitorId: _visitorId, props: props };
        if (entry.tier) wire.tier = entry.tier;
        if (entry.cohort) wire.cohort = entry.cohort;
        return wire;
    }

    /**
     * flush — send at most one batch of 50.
     *
     * Failure puts the batch back at the FRONT of the queue (order is kept)
     * and stops; the timer, the next pagehide or coming back online will try
     * again. A 4xx is different: the batch is dropped, because a batch the
     * server will not accept will never be accepted, and retrying it forever
     * would block everything queued behind it.
     */
    function flush(options) {
        var opts = options || {};
        init();
        return _ready.then(function () {
            if (!_enabled || !_endpoint || _optOut || _flushing || _queue.length === 0) return 0;
            _flushing = true;

            var taken = _queue.slice(0, MX_MAX_BATCH);
            _queue = _queue.slice(taken.length);

            var events = [];
            for (var i = 0; i < taken.length; i++) {
                var wire = toWire(taken[i]);
                if (wire) events.push(wire);
            }
            if (events.length === 0) {
                _flushing = false;
                persist();
                return 0;
            }

            var body = JSON.stringify({ events: events });
            var headers = { 'content-type': 'application/json' };
            // The keepalive path cannot await a token refresh, so it uses the
            // last one seen. A stale token is not a failure: the Lambda counts
            // the batch anonymously rather than rejecting it.
            return (opts.keepalive ? Promise.resolve(_token) : freshToken()).then(function (token) {
                if (token) headers.authorization = 'Bearer ' + token;
                return fetch(_endpoint, {
                    method: 'POST',
                    headers: headers,
                    body: body,
                    keepalive: !!opts.keepalive
                });
            }).then(function (res) {
                if (res && res.ok) {
                    _sent += 1;
                    persist();
                    return events.length;
                }
                if (res && res.status >= 400 && res.status < 500 && res.status !== 429) {
                    console.warn('[MetricsService] batch refused (' + res.status + ') and dropped — the two copies of event-schema.json may have drifted.');
                    persist();
                    return 0;
                }
                _queue = mxTrim(taken.concat(_queue), MX_MAX_QUEUE);
                persist();
                return 0;
            })['catch'](function () {
                _queue = mxTrim(taken.concat(_queue), MX_MAX_QUEUE);
                persist();
                return 0;
            }).then(function (count) {
                _flushing = false;
                return count;
            });
        })['catch'](function () {
            _flushing = false;
            return 0;
        });
    }

    /** freshToken — the signed-in access token from AuthService (Cognito, or
     *  Clerk per AUTH_PROVIDER), or null. Never rejects. */
    function freshToken() {
        try {
            if (window.mdAuth && typeof window.mdAuth.getAccessToken === 'function') {
                return Promise.resolve(window.mdAuth.getAccessToken()).then(function (token) {
                    _token = (typeof token === 'string' && token) ? token : null;
                    return _token;
                })['catch'](function () { return _token; });
            }
        } catch (err) { /* fall through */ }
        _token = null;
        return Promise.resolve(null);
    }

    /* --- introspection (the headless suite reads these) -------------- */

    function state() {
        return {
            enabled: _enabled,
            endpoint: _endpoint,
            visitorId: _visitorId,
            queued: _queue.length,
            batchesSent: _sent,
            optOut: _optOut,
            tier: _context.tier,
            cohort: _context.cohort
        };
    }

    return {
        init: init,
        track: track,
        flush: flush,
        state: state,
        // Pure helpers, exported so the suite can unit-test the allowlist
        // without a page of its own.
        cleanProps: function (name, props) { return mxCleanProps(_schema, name, props); },
        schema: function () { return _schema; }
    };
})();

window.MetricsService = MetricsService;
window.mxCleanProps = mxCleanProps;
window.mxAssessmentCounts = mxAssessmentCounts;
window.mxEnvelopeValue = mxEnvelopeValue;
window.mxTrim = mxTrim;
window.mxUuid = mxUuid;
