// Fixtures tab — Add/Edit modal slice (KL-164 split).
//
// Owns: catalog + channel-types loading, Add modal state and flow,
// Edit modal state and flow (including channel-row editor, restamp,
// and RDM repatch), role-lock UX, address-overlap detection, and the
// suggest-address pipeline.
//
// Lives in the same `$store.fixtures` namespace as the list and
// universemap slices. Methods that need list state (e.g. `allFixtures`
// for overlap detection, `refresh()` after save) reach them via `this.`
// at runtime — the three slices are composed into a single store.

export function editorSlice() {
    return {
        // --- Catalog (loaded once on first modal open) ---
        catalogTemplates: [],
        catalogLoading: false,
        templateFilter: '',
        channelTypes: [],
        channelTypeHints: {},

        // --- Add modal state ---
        addShown: false,
        addForm: {
            templateId: '',
            name: '',
            count: 1,
            universe: 0,
            startAddress: '',
            role: 'ambient',
            // KL-152: tracks whether startAddress is the suggested value
            // (true → show "auto" hint) vs. user-typed (false → no hint).
            // universeFull → block submit + show "switch universes" copy.
            startAddressAuto: false,
            universeFull: false,
            // KL-149: role lock state. Templated fixtures start LOCKED so
            // the user sees we picked a sensible default for them; clicking
            // Override unlocks editing. Custom (no template) stays unlocked
            // because there's no default to lock to.
            roleLocked: false,
        },
        addError: '',
        addBusy: false,

        // --- Edit modal state ---
        editShown: false,
        editingIndex: null,
        editingEntry: null,  // raw snapshot for restamp / advanced-field preservation
        editForm: {
            name: '',
            universe: 0,
            address: 1,
            role: 'ambient',
            // KL-149: same lock pattern as Add modal. openEdit() decides
            // the initial lock state based on whether the entry was
            // stamped_from a template (= we have a default to lock to).
            roleLocked: false,
        },
        editChannels: [],   // [{type, default}, ...]
        editError: '',
        editBusy: false,
        rdmRepatchBusy: false,
        rdmRepatchLabel: 'Push address (RDM)',

        // --- Computed: Add modal ---
        get addTemplateHint() {
            const id = this.addForm.templateId;
            if (!id) return 'A bare-bones fixture with one dimmer channel. Edit fully after save.';
            const t = this.catalogTemplates.find(x => x.id === id);
            return t
                ? `${t.channels}ch ${t.category}. Channels will be pre-filled from the template.`
                : '';
        },
        get addTemplateOptions() {
            const q = this.templateFilter.trim().toLowerCase();
            if (!q) {
                // KL-136: with the OFL catalog at ~2500 entries, rendering
                // every option on initial open lags WKWebView noticeably.
                // Cap unfiltered to the first 100 alphabetical and rely
                // on the filter input for navigation.
                return this.catalogTemplates.slice(0, 100);
            }
            return this.catalogTemplates.filter(t => {
                const hay = `${t.id} ${t.name} ${t.manufacturer || ''} ${t.category || ''}`.toLowerCase();
                return hay.includes(q);
            });
        },
        get addTemplateOverflowCount() {
            // How many catalog entries are hidden behind the cap when no
            // filter is active. Surfaces in the UI as "Type to filter…
            // (N more available)" so users know to refine instead of
            // scrolling the truncated list.
            if (this.templateFilter.trim()) return 0;
            return Math.max(0, this.catalogTemplates.length - 100);
        },

        // --- Computed: Edit modal ---
        get editOriginText() {
            if (!this.editingEntry) return '';
            return this.editingEntry.stamped_from
                ? `Stamped from ${this.editingEntry.stamped_from}. Re-stamp resets the channel map to the catalog version but keeps name, address, and role.`
                : 'Custom fixture — edit channels freely.';
        },
        get showRestamp() {
            return !!(this.editingEntry && this.editingEntry.stamped_from);
        },
        get showRdmRepatch() {
            return !!(this.editingEntry && this.editingEntry.rdm_uid);
        },

        // --- Methods: catalog ---
        async loadCatalog() {
            if (this.catalogTemplates.length) return;
            this.catalogLoading = true;
            try {
                const r = await fetch('/api/fixtures/catalog');
                const body = await r.json();
                this.catalogTemplates = body.templates || [];
            } catch (e) {
                this.catalogTemplates = [];
            } finally {
                this.catalogLoading = false;
            }
        },

        async loadChannelTypes() {
            if (this.channelTypes.length) return;
            try {
                const r = await fetch('/api/fixtures/channel-types');
                const body = await r.json();
                this.channelTypes = body.types || [];
            } catch (e) {
                this.channelTypes = [];
            }
            const hints = {};
            this.channelTypes.forEach(t => {
                hints[t.type] = t.description || '';
            });
            this.channelTypeHints = hints;
        },

        channelTypeHintFor(type) {
            const t = (type || '').trim();
            if (!t) return '';
            return this.channelTypeHints[t] || 'Custom type — role engine will log a warning.';
        },

        // KL-146: types where direction varies fixture-to-fixture and an
        // invert toggle is meaningful. Engine logic in roles.py handles
        // any other type cleanly (the field is ignored).
        isInvertibleChannelType(type) {
            const t = (type || '').trim();
            return t === 'zoom' || t === 'iris' || t === 'frost'
                || t === 'color_temperature' || t === 'dimmer';
        },

        // --- Methods: Add modal ---
        async openAdd() {
            // KL-143: open the modal IMMEDIATELY, then load the catalog
            // in the background. The dropdown shows "Loading templates…"
            // while the fetch is in flight (rare with the index file in
            // place — sub-100ms — but real on cold-rebuild edges).
            this.addError = '';
            this.addForm = {
                templateId: '',
                name: '',
                count: 1,
                universe: 0,
                startAddress: '',
                role: 'ambient',
                startAddressAuto: false,
                universeFull: false,
                // KL-149: Custom is the initial template selection, so
                // role is unlocked at open. Picking a template flips
                // this to true via applyDefaultRoleForTemplate().
                roleLocked: false,
            };
            this.templateFilter = '';
            this.addShown = true;
            setTimeout(() => document.getElementById('fx-name')?.focus(), 0);
            // Catalog and address suggestion both fetch in parallel.
            // Catalog can take up to ~100ms; address suggestion is
            // single-digit ms because allFixtures is already loaded.
            this.loadCatalog();
            this.refreshSuggestedAddress();
        },

        // KL-152: ask the backend for the next free start address that
        // fits the currently-selected template's channel count on the
        // currently-selected universe. Updates addForm.startAddress
        // (the actual field value) and addForm.startAddressAuto/
        // universeFull (the UI hint flags). Called on modal open and
        // whenever template or universe changes.
        async refreshSuggestedAddress() {
            const channels = this._channelsForTemplate(this.addForm.templateId);
            if (channels <= 0) {
                this.addForm.startAddress = '';
                this.addForm.startAddressAuto = false;
                this.addForm.universeFull = false;
                return;
            }
            const universe = Number(this.addForm.universe) || 0;
            // KL-153: ask for a block big enough for ALL N copies, not
            // just the first one. The server now does the same on save.
            const count = Math.max(1, Number(this.addForm.count) || 1);
            try {
                const r = await fetch(
                    `/api/fixtures/suggest-address?channels=${channels}&universe=${universe}&count=${count}`,
                );
                const body = await r.json().catch(() => ({}));
                if (r.status === 409 && body.universe_full) {
                    this.addForm.startAddress = '';
                    this.addForm.startAddressAuto = false;
                    this.addForm.universeFull = true;
                    return;
                }
                if (r.ok && typeof body.address === 'number') {
                    this.addForm.startAddress = body.address;
                    this.addForm.startAddressAuto = true;
                    this.addForm.universeFull = false;
                }
            } catch (_) {
                // Network glitch — leave the field blank, the server
                // will allocate on save (existing fallback behavior).
                this.addForm.startAddressAuto = false;
                this.addForm.universeFull = false;
            }
        },

        _channelsForTemplate(id) {
            // Custom (no template) = 1ch dimmer; matches stamp.py default.
            if (!id) return 1;
            const t = this.catalogTemplates.find(x => x.id === id);
            return t?.channels || 0;
        },

        // KL-149: when the user picks a template in the Add modal, set
        // the role to the template's recommended default AND lock the
        // dropdown so the user sees we picked it for them. Custom (no
        // template) → unlock; there's no default to lock to.
        // Switching templates re-derives the default and re-locks even
        // if the user previously clicked Override on a different
        // template — each new template is a fresh decision.
        applyDefaultRoleForTemplate() {
            const id = this.addForm.templateId;
            if (!id) {
                this.addForm.roleLocked = false;
                return;
            }
            const t = this.catalogTemplates.find(x => x.id === id);
            if (t?.default_role) {
                this.addForm.role = t.default_role;
            }
            this.addForm.roleLocked = true;
        },

        // KL-149: pure helper — returns the default_role for a template
        // id by looking it up in the loaded catalog. Returns null when
        // the catalog isn't loaded yet or the id is unknown. Used by
        // both Add and Edit flows.
        _defaultRoleForTemplate(id) {
            if (!id) return null;
            const t = this.catalogTemplates.find(x => x.id === id);
            return t?.default_role || null;
        },

        // KL-149: clicked the Override button → unlock the dropdown so
        // the user can change the role. Resets do NOT persist the
        // unlock; closing and reopening the modal locks again.
        unlockAddRole() {
            this.addForm.roleLocked = false;
        },

        unlockEditRole() {
            this.editForm.roleLocked = false;
        },

        // KL-149: dynamic label for the Add modal role field.
        //   - No template (Custom)            → "Role"
        //   - Locked                          → "Default role"
        //   - Unlocked                        → "Override role"
        get addRoleLabel() {
            if (!this.addForm.templateId) return 'Role';
            return this.addForm.roleLocked ? 'Default role' : 'Override role';
        },

        // KL-149: dynamic label for the Edit modal role field with the
        // saved-vs-default detection per the option (b) treatment.
        //   - Custom fixture (no stamped_from) → "Role"
        //   - Locked + saved == default        → "Default role"
        //   - Locked + saved != default        → "Saved role" (user previously overrode)
        //   - Unlocked                         → "Override role"
        get editRoleLabel() {
            const stampedFrom = this.editingEntry?.stamped_from;
            if (!stampedFrom) return 'Role';
            if (!this.editForm.roleLocked) return 'Override role';
            const def = this._defaultRoleForTemplate(stampedFrom);
            const saved = this.editingEntry?.role;
            if (def && saved && saved !== def) return 'Saved role';
            return 'Default role';
        },

        // KL-149: whether the Override button should appear next to the
        // role dropdown. Only shows when locked AND a template is in
        // play (Custom fixtures have no lock UX).
        get showAddRoleOverrideButton() {
            return !!this.addForm.templateId && this.addForm.roleLocked;
        },
        get showEditRoleOverrideButton() {
            return !!this.editingEntry?.stamped_from && this.editForm.roleLocked;
        },

        // KL-155: walk allFixtures looking for one whose [address, address+channels-1]
        // range on the same universe overlaps the proposed [start, start+totalChannels-1]
        // range. Returns the first conflict as {name, start, end} or null when clean.
        // skipIndex is for the Edit modal — we don't want a fixture to flag itself.
        // Lives on the editor slice but called from universemap too via `this.`.
        findAddressOverlap(start, totalChannels, universe, skipIndex = null) {
            if (!Number.isFinite(start) || start < 1) return null;
            if (totalChannels <= 0) return null;
            const newLo = Number(start);
            const newHi = newLo + Number(totalChannels) - 1;
            const targetUniverse = Number(universe) || 0;
            for (let i = 0; i < this.allFixtures.length; i++) {
                if (skipIndex !== null && i === skipIndex) continue;
                const fx = this.allFixtures[i];
                if (Number(fx.universe || 0) !== targetUniverse) continue;
                const fxStart = Number(fx.address || 0);
                const fxChannels = Number(fx.channels || 0);
                if (fxStart < 1 || fxChannels <= 0) continue;
                const fxEnd = fxStart + fxChannels - 1;
                // Range intersect: not (newHi < fxStart || newLo > fxEnd)
                if (newHi >= fxStart && newLo <= fxEnd) {
                    return {name: fx.name || `Fixture #${i + 1}`, start: fxStart, end: fxEnd};
                }
            }
            return null;
        },

        // Computed: overlap info for the Add modal. Reactive on addForm.
        // Returns null when there's no overlap OR when we can't yet compute
        // (no address, no template loaded). UI uses null vs object as a flag.
        get addAddressOverlap() {
            const start = Number(this.addForm.startAddress);
            if (!start) return null;
            const channels = this._channelsForTemplate(this.addForm.templateId);
            if (channels <= 0) return null;
            const count = Math.max(1, Number(this.addForm.count) || 1);
            const totalChannels = channels * count;
            const universe = Number(this.addForm.universe) || 0;
            return this.findAddressOverlap(start, totalChannels, universe);
        },

        // Computed: overlap info for the Edit modal. Skips the fixture
        // being edited so editing in place doesn't trigger a self-conflict.
        get editAddressOverlap() {
            if (this.editingIndex === null) return null;
            const start = Number(this.editForm.address);
            if (!start) return null;
            // Edit modal lets the user change channels via the channel-row
            // editor; use the live editChannels length as the channel count
            // (each row = 1 channel). Falls back to the editing entry's
            // declared channels if the editor isn't populated yet.
            const channels = this.editChannels.length
                || Number(this.editingEntry?.channels || 0);
            if (channels <= 0) return null;
            const universe = Number(this.editForm.universe) || 0;
            return this.findAddressOverlap(start, channels, universe, this.editingIndex);
        },

        // KL-153: human-readable preview of the block being allocated.
        // Shows "5 × 5ch (25 channels, addresses 1–25)" so the user sees
        // the layout before committing. Empty string when there's nothing
        // useful to show (no template loaded yet, address blank, etc.).
        get bulkBlockPreview() {
            const count = Math.max(1, Number(this.addForm.count) || 1);
            const channels = this._channelsForTemplate(this.addForm.templateId);
            const start = Number(this.addForm.startAddress);
            if (count <= 1 || channels <= 0 || !start) return '';
            const total = count * channels;
            const end = start + total - 1;
            return `${count} × ${channels}ch = ${total} channels, addresses ${start}–${end}.`;
        },

        // KL-152: user typed in the start address field. Flip the auto
        // flag off so the hint stops claiming "auto-suggested" — they
        // own the value now.
        markStartAddressManual() {
            this.addForm.startAddressAuto = false;
        },

        closeAdd() {
            this.addShown = false;
        },

        async saveAdd() {
            this.addError = '';
            this.addBusy = true;
            const payload = {
                template_id: this.addForm.templateId || null,
                name: (this.addForm.name || '').trim() || null,
                count: Number(this.addForm.count) || 1,
                universe: Number(this.addForm.universe) || 0,
                role: this.addForm.role,
            };
            const startAddr = String(this.addForm.startAddress || '').trim();
            if (startAddr) payload.start_address = Number(startAddr);
            try {
                const r = await fetch('/api/fixtures', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify(payload),
                });
                const body = await r.json();
                if (!r.ok) {
                    this.addError = body.error || `Save failed (${r.status})`;
                    return;
                }
                this.closeAdd();
                await this.refresh();
            } catch (e) {
                this.addError = String(e);
            } finally {
                this.addBusy = false;
            }
        },

        // --- Methods: Edit modal ---
        async openEdit(idx) {
            const entry = this.allFixtures[idx];
            if (!entry) return;
            this.editingIndex = idx;
            this.editingEntry = JSON.parse(JSON.stringify(entry));
            this.editError = '';
            this.editForm = {
                name: entry.name || '',
                universe: entry.universe ?? 0,
                address: entry.address ?? 1,
                role: entry.role || 'ambient',
                // KL-149: stamped fixtures lock by default — we have a
                // recommended role to show. Custom fixtures (no
                // stamped_from) skip the lock UX since there's nothing
                // to default to.
                roleLocked: !!entry.stamped_from,
            };
            await this.loadChannelTypes();
            this.editChannels = this._channelMapToList(entry.channel_map || {});
            this.rdmRepatchLabel = 'Push address (RDM)';
            this.rdmRepatchBusy = false;
            this.editShown = true;
            setTimeout(() => document.getElementById('fx-edit-name')?.focus(), 0);
            // Catalog needed so the saved-vs-default label detection and
            // the re-stamp role reset can read the template's
            // default_role. Cached after first load.
            this.loadCatalog();
        },

        closeEdit() {
            this.editShown = false;
            this.editingIndex = null;
            this.editingEntry = null;
            this.editChannels = [];
        },

        _channelMapToList(channelMap) {
            const nums = Object.keys(channelMap).map(n => parseInt(n, 10)).sort((a, b) => a - b);
            return nums.map(num => {
                const defn = channelMap[num] || {};
                const typeVal = defn && typeof defn === 'object' ? (defn.type || '') : String(defn);
                const defaultVal = defn && defn.default !== undefined ? defn.default : '';
                const invertVal = !!(defn && defn.invert);
                return {type: typeVal, default: defaultVal, invert: invertVal};
            });
        },

        addChannelRow() {
            this.editChannels.push({type: '', default: '', invert: false});
        },

        removeChannelRow(idx) {
            this.editChannels.splice(idx, 1);
        },

        // Build the channel_map object from the reactive list. Preserves
        // advanced fields (range, positions, etc.) from the original
        // entry when type is unchanged — same logic as the old code.
        _collectChannelMap() {
            const map = {};
            this.editChannels.forEach((ch, i) => {
                const type = (ch.type || '').trim();
                if (!type) return;
                const num = i + 1;
                const entry = {type};
                const defaultRaw = String(ch.default || '').trim();
                if (defaultRaw !== '') entry.default = Number(defaultRaw);
                // KL-146: only emit `invert: true` — omit when false to
                // keep YAMLs lean. The loader treats missing as false.
                if (ch.invert) entry.invert = true;
                const original = this.editingEntry && this.editingEntry.channel_map
                    ? this.editingEntry.channel_map[num] : null;
                if (original && typeof original === 'object' && original.type === type) {
                    for (const k of ['range', 'positions', 'step', 'threshold', 'open', 'rotate_start', 'trigger_value']) {
                        if (original[k] !== undefined && entry[k] === undefined) {
                            entry[k] = original[k];
                        }
                    }
                }
                map[num] = entry;
            });
            return map;
        },

        async saveEdit() {
            if (this.editingIndex === null) return;
            this.editError = '';
            this.editBusy = true;
            const channelMap = this._collectChannelMap();
            if (!Object.keys(channelMap).length) {
                this.editError = 'Channel map cannot be empty.';
                this.editBusy = false;
                return;
            }
            const payload = {
                name: this.editForm.name.trim(),
                universe: Number(this.editForm.universe) || 0,
                address: Number(this.editForm.address) || 1,
                role: this.editForm.role,
                channel_map: channelMap,
            };
            try {
                const r = await fetch(`/api/fixtures/${this.editingIndex}`, {
                    method: 'PUT',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify(payload),
                });
                const body = await r.json();
                if (!r.ok) {
                    this.editError = body.error || `Save failed (${r.status})`;
                    return;
                }
                this.closeEdit();
                await this.refresh();
            } catch (e) {
                this.editError = String(e);
            } finally {
                this.editBusy = false;
            }
        },

        async restamp() {
            if (this.editingIndex === null || !this.editingEntry || !this.editingEntry.stamped_from) return;
            // KL-149: re-stamp now wipes any previous role override. The
            // confirm copy used to say "name, address, and role stay as-is";
            // it lies now, so reword it to match the new behavior.
            const newDefault = this._defaultRoleForTemplate(this.editingEntry.stamped_from);
            const willResetRole = newDefault && newDefault !== this.editForm.role;
            const roleLine = willResetRole
                ? ` Role resets to "${newDefault}" (the template default).`
                : '';
            if (!confirm(`Reset channels to the ${this.editingEntry.stamped_from} catalog version? Name and address stay as-is.${roleLine}`)) return;
            this.editError = '';
            // Stage the new role locally so the PUT body sends it AND
            // re-lock the dropdown. Mirrors the user's stated mental
            // model: "re-stamp = give me your suggestion again."
            if (newDefault) {
                this.editForm.role = newDefault;
                this.editForm.roleLocked = true;
            }
            try {
                const r = await fetch(`/api/fixtures/${this.editingIndex}`, {
                    method: 'PUT',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({
                        restamp_from: this.editingEntry.stamped_from,
                        name: this.editForm.name.trim(),
                        universe: Number(this.editForm.universe) || 0,
                        address: Number(this.editForm.address) || 1,
                        role: this.editForm.role,
                    }),
                });
                const body = await r.json();
                if (!r.ok) {
                    this.editError = body.error || `Re-stamp failed (${r.status})`;
                    return;
                }
                this.editingEntry = body.fixture;
                this.editChannels = this._channelMapToList(this.editingEntry.channel_map || {});
            } catch (e) {
                this.editError = String(e);
            }
        },

        async rdmRepatch() {
            if (this.editingIndex === null || !this.editingEntry || !this.editingEntry.rdm_uid) return;
            const newAddr = Number(this.editForm.address) || 0;
            if (newAddr < 1 || newAddr > 512) {
                this.editError = 'Address must be 1-512.';
                return;
            }
            if (!confirm(`Push address ${newAddr} to ${this.editingEntry.rdm_uid} over RDM? Engine must be stopped.`)) return;
            this.editError = '';
            this.rdmRepatchBusy = true;
            this.rdmRepatchLabel = 'Pushing…';
            try {
                const r = await fetch('/api/dmx/repatch', {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({fixture_index: this.editingIndex, address: newAddr}),
                });
                const body = await r.json();
                if (!r.ok) {
                    this.editError = body.error || `Re-patch failed (${r.status})`;
                    return;
                }
                this.rdmRepatchLabel = 'Pushed ✓';
                await this.refresh();
            } catch (e) {
                this.editError = `Re-patch failed: ${e}`;
            } finally {
                this.rdmRepatchBusy = false;
                setTimeout(() => {
                    this.rdmRepatchLabel = 'Push address (RDM)';
                }, 2000);
            }
        },
    };
}
