/**
 * SpecialistsService — data plane for Find a Specialist. NAMESPACED `sp` (this app has no
 * build step and no module system — every top-level `var` becomes a `window` global, and
 * `sp`/`spg` are the prefixes the specialists-api-reference-pack reserves — see its
 * INTEGRATION-GUIDE.md §10). Owns fetch + a bounded results cache + the saved location.
 *
 * ES5 only (Babel 6 standalone): no arrow functions, template literals, const/let or
 * spread — "a syntax error here renders a blank panel with nothing in the console, so
 * keep it boring" (AirQualityPanel.jsx's own header).
 *
 * Same-origin `/api/specialists*` only — never `api.molddetect.app` directly (the pack's
 * §2: a key shipped in a React bundle is a public key). The proxy (`moulddetect-chat`
 * backend) injects `x-api-key` from Secrets Manager and caches responses server-side too.
 *
 * ── SAVED LOCATION ───────────────────────────────────────────────────────────────────
 * A single record, `mould-detect-specialists-location`, so the directory can paint a
 * remembered location instantly on mount and skip the geolocation prompt entirely when it
 * is recent — the pattern WeatherPanel/AirQualityPanel use for readings, applied here to
 * "where the user is" instead. Coordinates are rounded to 4 decimal places (~11 m) before
 * storage, matching the precision AirQualityPanel/WeatherPanel already send to `/api/*`
 * (`at.lat.toFixed(4)`) — full GPS precision is needless disclosure for a value that is
 * about to become "which suburb", not "which driveway".
 *
 * ── RESULTS CACHE ────────────────────────────────────────────────────────────────────
 * Genuinely multi-entry, UNLIKE WeatherCache/AirCache's single overwritten record. Those
 * two cache "the current reading for wherever the user is right now" — one location makes
 * sense as one slot. A specialist search is keyed by BOTH location AND category filter, a
 * user can search several suburbs or switch categories in one session, and a wrong or
 * stale specialist list is a real-world referral (the pack, §10: "a bundled sample fixture
 * is not appropriate here"), so the cache exists purely to make a repeat search feel
 * instant, not to be the source of truth. Hence a small map of entries rather than one
 * slot — and hence the hard cap: **max 6 entries, LRU-evicted on save**, so memory/storage
 * stays bounded by construction rather than by hoping nobody searches ten suburbs in a row.
 *
 * Raw responses are cached VERBATIM, never a mapped view — same reasoning AirCache's
 * header gives: the directory page and the profile/contact pages could each grow their own
 * mapper over time, and caching a mapped shape would re-couple all of them to one schema.
 *
 * NO SAMPLE-DATA RUNG. WeatherCache/AirCache fall through to a bundled fixture when there
 * is no cache and no live network; specialists deliberately does not, per the pack's §10:
 * "a wrong specialist list is a real-world referral to a business that may not serve that
 * area." The fallback ladder here stops at: in-memory (page state, owned by the caller,
 * not this file) -> IndexedDB (this file) -> live `/api/specialists`.
 *
 * Every public method is Promise-based. `search`/`getById` NEVER reject — a network or
 * upstream failure resolves `{error:true, ...}` so the view never needs a `.catch()` to
 * stay correct (a forgotten catch was exactly how "Find a Specialist" rendered as
 * permanently empty once already — INTEGRATION-GUIDE.md §8). The location/cache
 * read/write methods also never reject, matching AirCache's contract, since a broken
 * cache must never block a search or a location display.
 */
var SpecialistsService = (function () {

    var LOCATION_KEY = 'mould-detect-specialists-location';
    var CACHE_KEY = 'mould-detect-specialists-cache';
    var SCHEMA_V = 1;
    var COORD_DECIMALS = 4;

    // A saved location older than this is treated as absent — the directory falls back to
    // detecting again rather than showing the user somewhere they no longer are.
    var LOCATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;

    // A cached search result older than this is treated as absent. 24h, matching
    // WeatherCache/AirCache.MAX_AGE_MS: the directory always labels a cached paint with
    // its age, so a day-old list is never presented as current.
    var RESULTS_MAX_AGE_MS = 24 * 60 * 60 * 1000;

    // Hard cap on distinct cached searches (see file header). Small deliberately.
    var CACHE_MAX_ENTRIES = 6;

    // Own localforage instance — same physical database as StorageService/WeatherCache/
    // AirCache (name/storeName must match to share the IndexedDB object store), own keys.
    var store = localforage.createInstance({
        name: 'MouldDetect',
        storeName: 'app_data',
    });

    function roundCoord(n) {
        var f = Math.pow(10, COORD_DECIMALS);
        return Math.round(n * f) / f;
    }

    /* ── Saved location ──────────────────────────────────────────────────────────── */

    /**
     * getSavedLocation() -> { v, savedAt, lat, lon, displayName } or null.
     * Resolves null when there is nothing usable: missing, wrong schema version, or older
     * than LOCATION_MAX_AGE_MS — the caller does not need to re-check freshness itself.
     * Never rejects.
     */
    function getSavedLocation() {
        return store.getItem(LOCATION_KEY)
            .then(function (rec) {
                if (!rec || typeof rec !== 'object') return null;
                if (rec.v !== SCHEMA_V) return null;
                if (typeof rec.savedAt !== 'number') return null;
                if (typeof rec.lat !== 'number' || typeof rec.lon !== 'number') return null;
                if (Date.now() - rec.savedAt > LOCATION_MAX_AGE_MS) return null;
                return rec;
            })
            ['catch'](function () { return null; });
    }

    /**
     * saveLocation(lat, lon, displayName) — overwrite the single saved-location record.
     * Called after a successful geolocation detect or a manual address search, so the
     * NEXT mount can skip the prompt. Void return; errors are swallowed.
     */
    function saveLocation(lat, lon, displayName) {
        var rec = {
            v: SCHEMA_V,
            savedAt: Date.now(),
            lat: roundCoord(lat),
            lon: roundCoord(lon),
            displayName: displayName || null,
        };
        return store.setItem(LOCATION_KEY, rec)
            .then(function () {})
            ['catch'](function () {});
    }

    /* ── Results cache (bounded, multi-entry, LRU) ──────────────────────────────── */

    function readCacheRecord() {
        return store.getItem(CACHE_KEY)
            .then(function (rec) {
                if (!rec || typeof rec !== 'object' || rec.v !== SCHEMA_V || !rec.entries) {
                    return { v: SCHEMA_V, entries: {} };
                }
                return rec;
            })
            ['catch'](function () { return { v: SCHEMA_V, entries: {} }; });
    }

    /**
     * load(cacheKey) -> the cached raw response, or null when there is nothing usable
     * (missing, wrong schema, or older than RESULTS_MAX_AGE_MS). Never rejects.
     */
    function load(cacheKey) {
        if (!cacheKey) return Promise.resolve(null);
        return readCacheRecord().then(function (rec) {
            var entry = rec.entries[cacheKey];
            if (!entry || typeof entry.savedAt !== 'number') return null;
            if (Date.now() - entry.savedAt > RESULTS_MAX_AGE_MS) return null;
            return entry; // { savedAt, raw }
        });
    }

    /**
     * save(cacheKey, raw) — write one entry, evicting the least-recently-saved entry first
     * if the cache is already at CACHE_MAX_ENTRIES. Re-saving an existing key refreshes its
     * position (moves it to "most recent") by virtue of getting a new `savedAt`. Void
     * return; errors are swallowed — a failed cache write must never break a search.
     */
    function save(cacheKey, raw) {
        if (!cacheKey) return Promise.resolve();
        return readCacheRecord().then(function (rec) {
            var entries = rec.entries;
            entries[cacheKey] = { savedAt: Date.now(), raw: raw };

            var keys = Object.keys(entries);
            if (keys.length > CACHE_MAX_ENTRIES) {
                keys.sort(function (a, b) { return entries[a].savedAt - entries[b].savedAt; });
                var toEvict = keys.length - CACHE_MAX_ENTRIES;
                for (var i = 0; i < toEvict; i++) { delete entries[keys[i]]; }
            }

            return store.setItem(CACHE_KEY, { v: SCHEMA_V, entries: entries });
        })
            .then(function () {})
            ['catch'](function () {});
    }

    /* ── Live fetch ──────────────────────────────────────────────────────────────── */

    /**
     * Shared response handling for both fetch helpers below. Resolves the parsed JSON on
     * 2xx; on any other status, parses the platform's `{error:true, reason, ...}` envelope
     * and resolves (never rejects) `{error:true, status, reason, didYouMean, states}` so
     * the caller never needs its own try/catch to stay in a correct UI state. A 304 (only
     * reachable if a caller ever adds conditional-request support here) resolves null.
     */
    function handleResponse(res) {
        if (res.status === 304) return null;
        return res.json()
            ['catch'](function () { return null; })
            .then(function (body) {
                if (res.ok) return body;
                var b = body || {};
                return {
                    error: true,
                    status: res.status,
                    reason: typeof b.reason === 'string' ? b.reason : ('HTTP ' + res.status),
                    didYouMean: Array.isArray(b.did_you_mean) ? b.did_you_mean : null,
                    states: Array.isArray(b.states) ? b.states : null,
                };
            });
    }

    /**
     * search({ q, category, lat, lon, limit, offset, status }, opts) -> Promise<raw response
     * | {error:true, ...}>. `opts.signal` (an AbortController's signal) is passed straight
     * through to fetch so the caller can cancel an in-flight search (debounce, unmount).
     *
     * ONLY `q` is sent for free text (INTEGRATION-GUIDE.md §10: "Use `q`, not `suburb`" —
     * client-side suburb/postcode/region classification is a documented bug the pack
     * traces to a real 404). `lat`/`lon` are the separate "near me" path and are sent only
     * when both are present.
     */
    /* SPECIALIST_DEMO_LIST — TESTING override (flag, default OFF, never ship enabled):
       serves the local 30-record stub fixture instead of the live API. Fake Acme-style
       names; every contact email routes to Rozario's own inbox so a demo can never
       contact a real business. Same response shape as /api/specialists, so every view
       and cache path behaves identically. */
    var _spDemoCache = null;
    function _spDemoFixture() {
        if (_spDemoCache) return Promise.resolve(_spDemoCache);
        return fetch('components/SpecialistsService/demo-specialists.json')
            .then(function (r) { return r.json(); })
            .then(function (j) { _spDemoCache = j; return j; })
            ['catch'](function () { return null; });
    }
    function _spDemoEnabled() {
        return typeof FeatureFlags !== 'undefined' && FeatureFlags.isEnabled && FeatureFlags.isEnabled('SPECIALIST_DEMO_LIST');
    }

    function search(query, opts) {
        query = query || {};
        opts = opts || {};

        if (_spDemoEnabled()) {
            return _spDemoFixture().then(function (fx) {
                if (!fx) return { error: true, status: 0, reason: 'network' };
                var list = fx.specialists;
                var q = (query.q || '').toLowerCase();
                if (q) list = list.filter(function (sp) {
                    return (sp.name + ' ' + sp.location.suburb + ' ' + sp.location.postcode).toLowerCase().indexOf(q) !== -1;
                });
                if (query.category) list = list.filter(function (sp) { return sp.categories.indexOf(query.category) !== -1; });
                var out = {}; for (var k in fx) out[k] = fx[k];
                out.specialists = list; out.count = list.length;
                return out;
            });
        }

        var params = new URLSearchParams();
        if (query.q) { params.set('q', query.q); }
        if (query.category) { params.set('category', query.category); }
        if (typeof query.lat === 'number' && typeof query.lon === 'number') {
            params.set('lat', String(query.lat));
            params.set('lon', String(query.lon));
        }
        if (query.limit) { params.set('limit', String(query.limit)); }
        if (query.offset) { params.set('offset', String(query.offset)); }
        if (query.status) { params.set('status', query.status); }

        return fetch('/api/specialists?' + params.toString(), {
            headers: { 'Accept': 'application/json' },
            signal: opts.signal,
        })
            .then(handleResponse)
            ['catch'](function (err) {
                // NEVER throws to the view (house rule) — including a caller-initiated
                // abort. `reason:'aborted'` lets the caller tell "I cancelled this" apart
                // from "the network actually failed" without a try/catch of its own.
                var aborted = err && err.name === 'AbortError';
                return { error: true, status: 0, reason: aborted ? 'aborted' : 'network' };
            });
    }

    /**
     * getById(id, opts) -> Promise<specialist | {error:true, ...}>. Same never-throws
     * contract as search() above, including the same `reason:'aborted'` distinction.
     */
    function getById(id, opts) {
        opts = opts || {};
        if (_spDemoEnabled()) {
            return _spDemoFixture().then(function (fx) {
                if (!fx) return { error: true, status: 0, reason: 'network' };
                for (var i = 0; i < fx.specialists.length; i++) {
                    if (fx.specialists[i].id === id) return fx.specialists[i];
                }
                return { error: true, status: 404, reason: 'not_found' };
            });
        }
        return fetch('/api/specialists/' + encodeURIComponent(id), {
            headers: { 'Accept': 'application/json' },
            signal: opts.signal,
        })
            .then(handleResponse)
            ['catch'](function (err) {
                var aborted = err && err.name === 'AbortError';
                return { error: true, status: 0, reason: aborted ? 'aborted' : 'network' };
            });
    }

    return {
        getSavedLocation: getSavedLocation,
        saveLocation: saveLocation,
        load: load,
        save: save,
        search: search,
        getById: getById,
        LOCATION_MAX_AGE_MS: LOCATION_MAX_AGE_MS,
        RESULTS_MAX_AGE_MS: RESULTS_MAX_AGE_MS,
        CACHE_MAX_ENTRIES: CACHE_MAX_ENTRIES,
    };
})();

window.SpecialistsService = SpecialistsService;
