import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
import {
  type WizardState,
  INITIAL_WIZARD_STATE,
  type Region,
  type SelectedComponent,
  type CloudProvider,
  type NodeSize,
  type TopologyTemplate,
  type ProvisionResult,
  type MarketplaceBrand,
} from './model'
import { parseDeploymentID } from '@/shared/types/deployment'
import {
  findComponent,
  findProduct,
  isMandatory as isMandatoryComponent,
  resolveTransitiveDependencies,
  resolveTransitiveDependents,
  resolveProductComponentClosure,
  componentsByProduct,
  productByComponent,
  MANDATORY_COMPONENT_IDS,
  computeDefaultSelection,
} from '@/pages/wizard/steps/componentGroups'

/**
 * Normalise any caller-supplied component list (legacy SelectedComponent[]
 * records OR plain string[]) to a sorted, de-duplicated string[]. The
 * marketplace-style wizard stores ids only and treats the catalog as the
 * single source of truth for tier / description / dependency metadata.
 */
function normaliseComponentIds(input: unknown): string[] {
  if (!Array.isArray(input)) return []
  const ids = input
    .map((entry) => {
      if (typeof entry === 'string') return entry
      if (entry && typeof entry === 'object' && 'id' in entry) {
        return String((entry as { id: unknown }).id)
      }
      return null
    })
    .filter((v): v is string => !!v)
  return [...new Set(ids)].sort()
}

/**
 * Initial selection at first wizard run: every mandatory + recommended
 * component plus their transitive deps. Persisted state overrides this on
 * subsequent runs (see merge() below).
 */
function computeInitialComponentSelection(): string[] {
  return [...computeDefaultSelection()].sort()
}

interface WizardActions {
  setStep: (step: number) => void
  markStepComplete: (step: number) => void
  /**
   * Set / clear the wizard's current deployment id. Accepts a raw
   * `string` for ergonomic call-sites (e.g. the StepReview launch
   * handler reading `data.id` from the catalyst-api response), but
   * routes EVERY non-null value through `parseDeploymentID()` so the
   * persisted value is guaranteed to be a real 16-char hex id. A
   * malformed id throws synchronously — the wizard surfaces this as
   * a launch failure rather than persisting an invalid state.
   * Closes #749 + #754.
   */
  setDeploymentId: (id: string | null) => void
  reset: () => void

  // Step 1 — Org
  setOrgName: (name: string) => void
  setOrgDomain: (domain: string) => void
  setOrgEmail: (email: string) => void
  setOrgIndustry: (industry: string) => void
  setOrgSize: (size: string) => void
  setOrgHeadquarters: (hq: string) => void
  setOrgCompliance: (tags: string[]) => void

  // Step 1 — Sovereign domain (pool, byo-manual, byo-api)
  setSovereignDomainMode: (mode: import('./model').DomainMode) => void
  setSovereignPoolDomain: (id: string) => void
  setSovereignSubdomain: (subdomain: string) => void
  setSovereignByoDomain: (domain: string) => void

  // Step 1 — BYO-api registrar credentials (#169 BYO Flow B)
  setRegistrarType: (registrar: import('./model').RegistrarType | null) => void
  setRegistrarToken: (token: string) => void
  setRegistrarTokenValidated: (validated: boolean) => void
  clearRegistrarCredentials: () => void

  // Step 2 — Topology (resets per-region providers when topology changes)
  setTopology: (topology: TopologyTemplate) => void

  // Step 3 — Per-region provider
  setRegionProvider: (regionIndex: number, provider: CloudProvider) => void
  setRegionCloudRegion: (regionIndex: number, cloudRegion: string) => void
  applyProviderToAll: (provider: CloudProvider, regionCount: number) => void

  // Step 3 — Per-region sizing (lives inside the provider step alongside
  // the provider+region pickers, since SKU vocabulary is per-provider).
  setRegionControlPlaneSize: (regionIndex: number, sizeId: string) => void
  setRegionWorkerSize: (regionIndex: number, sizeId: string) => void
  setRegionWorkerCount: (regionIndex: number, count: number) => void

  // Step 4 — Per-provider credentials
  setProviderToken: (provider: CloudProvider, token: string) => void
  setProviderValidated: (provider: CloudProvider, validated: boolean) => void

  // Compat setters
  setProvider: (provider: CloudProvider) => void
  setHetznerToken: (token: string) => void
  setHetznerProjectId: (projectId: string) => void
  setCredentialValidated: (validated: boolean) => void

  // Step 4 — Hetzner Object Storage credentials (issue #371)
  setObjectStorageRegion: (region: 'fsn1' | 'nbg1' | 'hel1' | '') => void
  setObjectStorageAccessKey: (key: string) => void
  setObjectStorageSecretKey: (key: string) => void
  setObjectStorageValidated: (validated: boolean) => void

  // Step 4 — SSH keypair (Mode A: auto-generate / Mode B: paste existing)
  setSshPublicKey: (key: string) => void
  /** Mode A — captures a freshly generated keypair returned by
   *  /api/v1/sshkey/generate. The privateKey is held only until the next
   *  paste (Mode B) replaces it. */
  setSshGenerated: (publicKey: string, privateKey: string, fingerprint: string) => void
  /** Mode B / reset — pasted-key mode clears the private-key blob so the
   *  wizard never accidentally exposes a stale private half. */
  clearSshPrivateKey: () => void

  // AIR-GAP add-on
  setAirgap: (airgap: boolean) => void

  // Step 5 — Components
  setGroupComponents: (groupId: string, componentIds: string[]) => void
  toggleGroupComponent: (groupId: string, componentId: string, allIds: string[]) => void
  setComponentsAppliedForProfile: (hash: string | null) => void
  /** Toggle a Blueprint in the unified marketplace card grid (StepComponents). */
  toggleBlueprint: (blueprintId: string) => void
  /** Replace the entire selectedBlueprints list (e.g. when a preset is picked). */
  setSelectedBlueprints: (ids: string[]) => void

  // Step 5 — Corporate platform components (the 60+ catalog)
  /**
   * Add a component to the selection. Cascades: every transitive dep listed
   * in componentGroups.ts is added too. Idempotent — re-adding an
   * already-selected id is a no-op. Returns the new sorted selection.
   */
  addComponent: (id: string) => void
  /**
   * Remove a component from the selection. Cascades: every component that
   * (transitively) depends on it is removed too. Mandatory components
   * cannot be removed — call is a no-op. Idempotent. Returns the new
   * sorted selection.
   */
  removeComponent: (id: string) => void
  /** Replace the entire component selection (used by tests + presets). */
  setSelectedComponents: (ids: string[]) => void
  /** Reset the component selection back to "all mandatory + all recommended + their deps". */
  resetSelectedComponentsToDefault: () => void

  /**
   * Add an entire product family — every component in the product, every
   * component in transitively-reached `familyDependencies` products, plus
   * every component-level transitive dep of those. Idempotent: no-op for
   * products already fully selected.
   *
   * Used by the "Select entire product" header CTA in StepComponents and
   * indirectly when cascading from a single component pulls its full
   * product family in (see addComponent → product cascade).
   */
  addProduct: (productId: string) => void

  /**
   * Remove an entire product family — every NON-mandatory component in the
   * product. Mandatory components are protected even when the operator
   * chooses "Remove product" (they're foundational infra). Components
   * outside the product that aren't mandatory but transitively depend on
   * one of the product's components are also dropped (cascade-remove
   * semantics, mirrored after `removeComponent`).
   */
  removeProduct: (productId: string) => void

  // Step 7 — Provisioning result (captured by SSE done event)
  setLastProvisionResult: (result: ProvisionResult | null) => void

  // Legacy
  addRegion: (region: Region) => void
  removeRegion: (id: string) => void
  setControlPlaneSize: (size: NodeSize) => void
  setWorkerSize: (size: NodeSize) => void
  setWorkerCount: (count: number) => void
  setHaEnabled: (enabled: boolean) => void

  // Step — Marketplace (issue #710 wave 3a). Both setters are simple
  // mirrors of the setHaEnabled pattern; the brand setter takes a
  // partial so callers can edit one field at a time.
  setMarketplaceEnabled: (enabled: boolean) => void
  setMarketplaceBrand: (brand: Partial<MarketplaceBrand>) => void

  toggleComponent: (component: SelectedComponent) => void
  setComponents: (components: SelectedComponent[]) => void
}

type WizardStore = WizardState & WizardActions

export const useWizardStore = create<WizardStore>()(
  devtools(
    persist(
      (set) => ({
        ...INITIAL_WIZARD_STATE,

        setStep: (step) => set({ currentStep: step }, false, 'wizard/setStep'),
        markStepComplete: (step) =>
          set(
            (s) => ({
              completedSteps: s.completedSteps.includes(step)
                ? s.completedSteps
                : [...s.completedSteps, step],
            }),
            false,
            'wizard/markStepComplete'
          ),
        setDeploymentId: (deploymentId) =>
          set(
            {
              // Branded-type guard — a raw string from anywhere
              // (catalyst-api response, Storybook, test fixture) is
              // re-validated here so the persisted state can never hold
              // a malformed id. Issues #749 + #754.
              deploymentId:
                deploymentId === null ? null : parseDeploymentID(deploymentId),
            },
            false,
            'wizard/setDeploymentId',
          ),
        reset: () => set(INITIAL_WIZARD_STATE, false, 'wizard/reset'),

        setOrgName: (orgName) => set({ orgName }, false, 'wizard/setOrgName'),
        setOrgDomain: (orgDomain) => set({ orgDomain }, false, 'wizard/setOrgDomain'),
        setOrgEmail: (orgEmail) => set({ orgEmail }, false, 'wizard/setOrgEmail'),
        setOrgIndustry: (orgIndustry) => set({ orgIndustry }, false, 'wizard/setOrgIndustry'),
        setOrgSize: (orgSize) => set({ orgSize }, false, 'wizard/setOrgSize'),
        setOrgHeadquarters: (orgHeadquarters) => set({ orgHeadquarters }, false, 'wizard/setOrgHeadquarters'),
        setOrgCompliance: (orgCompliance) => set({ orgCompliance }, false, 'wizard/setOrgCompliance'),

        // Sovereign-domain setters
        setSovereignDomainMode: (sovereignDomainMode) =>
          set(
            () => {
              // Switching modes wipes anything that doesn't apply, so the
              // wizard never carries a stale registrar token into byo-manual
              // (or vice versa) and never silently keeps the typed BYO
              // domain when the user switches back to pool.
              if (sovereignDomainMode === 'pool') {
                return {
                  sovereignDomainMode,
                  sovereignByoDomain: '',
                  registrarType: null,
                  registrarToken: '',
                  registrarTokenValidated: false,
                }
              }
              if (sovereignDomainMode === 'byo-manual') {
                return {
                  sovereignDomainMode,
                  sovereignSubdomain: '',
                  registrarType: null,
                  registrarToken: '',
                  registrarTokenValidated: false,
                }
              }
              // byo-api — keep typed domain; force a re-validation.
              return {
                sovereignDomainMode,
                sovereignSubdomain: '',
                registrarTokenValidated: false,
              }
            },
            false,
            'wizard/setSovereignDomainMode',
          ),
        setSovereignPoolDomain: (sovereignPoolDomain) =>
          set({ sovereignPoolDomain }, false, 'wizard/setSovereignPoolDomain'),
        setSovereignSubdomain: (sovereignSubdomain) =>
          set({ sovereignSubdomain: sovereignSubdomain.toLowerCase().replace(/\s+/g, '') },
              false, 'wizard/setSovereignSubdomain'),
        setSovereignByoDomain: (sovereignByoDomain) =>
          set({ sovereignByoDomain: sovereignByoDomain.toLowerCase().trim() },
              false, 'wizard/setSovereignByoDomain'),

        // BYO-api registrar credentials (#169) — token never persists to
        // localStorage (partialize() strips it). Every mutation invalidates
        // the validated flag so a typo'd or rotated token must be re-proved.
        setRegistrarType: (registrarType) =>
          set({ registrarType, registrarTokenValidated: false }, false, 'wizard/setRegistrarType'),
        setRegistrarToken: (registrarToken) =>
          set({ registrarToken, registrarTokenValidated: false }, false, 'wizard/setRegistrarToken'),
        setRegistrarTokenValidated: (registrarTokenValidated) =>
          set({ registrarTokenValidated }, false, 'wizard/setRegistrarTokenValidated'),
        clearRegistrarCredentials: () =>
          set({ registrarType: null, registrarToken: '', registrarTokenValidated: false },
              false, 'wizard/clearRegistrarCredentials'),

        // Reset regionProviders, regionCloudRegions, AND the per-region
        // sizing arrays when topology changes — the new topology may have
        // a different region count, and a stale SKU at index 3 carried
        // over from CITADEL into a SOLO would silently feed the cost
        // rollup an unreachable region. The legacy single workerCount
        // field is also reset (StepProvider seeds per-region counts now).
        setTopology: (topology) =>
          set(
            {
              topology,
              regionProviders: {},
              regionCloudRegions: {},
              regionControlPlaneSizes: [],
              regionWorkerSizes: [],
              regionWorkerCounts: [],
              providerValidated: {},
              providerTokens: {},
              workerCount: 0,
            },
            false,
            'wizard/setTopology',
          ),

        // Switching the provider for a region invalidates the SKUs that
        // were valid for the old provider (Hetzner cx32 means nothing on
        // Azure). The cloud-region is also wiped because the region
        // vocabulary is per-provider too. StepProvider re-pre-selects
        // sane defaults immediately after.
        setRegionProvider: (regionIndex, provider) =>
          set(
            (s) => {
              const cps = [...s.regionControlPlaneSizes]
              const wks = [...s.regionWorkerSizes]
              const wcs = [...s.regionWorkerCounts]
              while (cps.length <= regionIndex) cps.push('')
              while (wks.length <= regionIndex) wks.push('')
              while (wcs.length <= regionIndex) wcs.push(0)
              cps[regionIndex] = ''
              wks[regionIndex] = ''
              wcs[regionIndex] = 0
              return {
                regionProviders: { ...s.regionProviders, [regionIndex]: provider },
                regionCloudRegions: { ...s.regionCloudRegions, [regionIndex]: '' },
                regionControlPlaneSizes: cps,
                regionWorkerSizes: wks,
                regionWorkerCounts: wcs,
              }
            },
            false,
            'wizard/setRegionProvider',
          ),
        setRegionCloudRegion: (regionIndex, cloudRegion) =>
          set(
            (s) => ({ regionCloudRegions: { ...s.regionCloudRegions, [regionIndex]: cloudRegion } }),
            false,
            'wizard/setRegionCloudRegion'
          ),
        applyProviderToAll: (provider, regionCount) => {
          const regionProviders: Record<number, CloudProvider> = {}
          for (let i = 0; i < regionCount; i++) regionProviders[i] = provider
          set({ regionProviders }, false, 'wizard/applyProviderToAll')
        },

        // Per-region SKU mutators. Each writes ONLY into the index it was
        // given — adjacent indices are preserved untouched. The setters
        // pad the array with empty strings (for sizes) or zeros (for
        // counts) up to the requested index so a sparse selection from
        // an earlier topology choice doesn't leak NaN/undefined holes
        // into the cost rollup. The legacy single-valued field mirrors
        // index 0 so the existing solo-Hetzner fallback path keeps
        // working until the multi-region tofu wiring activates.
        setRegionControlPlaneSize: (regionIndex, sizeId) =>
          set(
            (s) => {
              const next = [...s.regionControlPlaneSizes]
              while (next.length <= regionIndex) next.push('')
              next[regionIndex] = sizeId
              const patch: Partial<WizardState> = { regionControlPlaneSizes: next }
              if (regionIndex === 0) patch.controlPlaneSize = sizeId
              return patch
            },
            false,
            'wizard/setRegionControlPlaneSize',
          ),
        setRegionWorkerSize: (regionIndex, sizeId) =>
          set(
            (s) => {
              const next = [...s.regionWorkerSizes]
              while (next.length <= regionIndex) next.push('')
              next[regionIndex] = sizeId
              const patch: Partial<WizardState> = { regionWorkerSizes: next }
              if (regionIndex === 0) patch.workerSize = sizeId
              return patch
            },
            false,
            'wizard/setRegionWorkerSize',
          ),
        setRegionWorkerCount: (regionIndex, count) =>
          set(
            (s) => {
              const safe = Math.max(0, Math.min(50, Math.floor(count)))
              const next = [...s.regionWorkerCounts]
              while (next.length <= regionIndex) next.push(0)
              next[regionIndex] = safe
              const patch: Partial<WizardState> = { regionWorkerCounts: next }
              if (regionIndex === 0) patch.workerCount = safe
              return patch
            },
            false,
            'wizard/setRegionWorkerCount',
          ),

        setProviderToken: (provider, token) =>
          set(
            (s) => ({ providerTokens: { ...s.providerTokens, [provider]: token } }),
            false,
            'wizard/setProviderToken'
          ),
        setProviderValidated: (provider, validated) =>
          set(
            (s) => ({ providerValidated: { ...s.providerValidated, [provider]: validated } }),
            false,
            'wizard/setProviderValidated'
          ),

        setProvider: (provider) =>
          set({ provider, credentialValidated: false, hetznerToken: '' }, false, 'wizard/setProvider'),
        setHetznerToken: (hetznerToken) => set({ hetznerToken }, false, 'wizard/setHetznerToken'),
        setHetznerProjectId: (hetznerProjectId) =>
          set({ hetznerProjectId: hetznerProjectId.trim() }, false, 'wizard/setHetznerProjectId'),
        setCredentialValidated: (credentialValidated) =>
          set({ credentialValidated }, false, 'wizard/setCredentialValidated'),

        // Hetzner Object Storage (issue #371). Each setter clears
        // objectStorageValidated so a region/key change forces a fresh
        // ListBuckets probe before Next is enabled — same pattern as the
        // Hetzner Cloud token credentialValidated flag.
        setObjectStorageRegion: (objectStorageRegion) =>
          set({ objectStorageRegion, objectStorageValidated: false }, false,
            'wizard/setObjectStorageRegion'),
        setObjectStorageAccessKey: (objectStorageAccessKey) =>
          set({ objectStorageAccessKey, objectStorageValidated: false }, false,
            'wizard/setObjectStorageAccessKey'),
        setObjectStorageSecretKey: (objectStorageSecretKey) =>
          set({ objectStorageSecretKey, objectStorageValidated: false }, false,
            'wizard/setObjectStorageSecretKey'),
        setObjectStorageValidated: (objectStorageValidated) =>
          set({ objectStorageValidated }, false, 'wizard/setObjectStorageValidated'),

        setSshPublicKey: (sshPublicKey) =>
          set(
            // Pasted key — clear any private blob held over from a Mode-A
            // generation in the same session. Fingerprint goes to '' since
            // we don't ship a JS hashing library to recompute it client-side.
            { sshPublicKey, sshPrivateKeyOnce: '', sshKeyGeneratedThisSession: false, sshFingerprint: '' },
            false,
            'wizard/setSshPublicKey',
          ),
        setSshGenerated: (publicKey, privateKey, fingerprint) =>
          set(
            {
              sshPublicKey: publicKey,
              sshPrivateKeyOnce: privateKey,
              sshFingerprint: fingerprint,
              sshKeyGeneratedThisSession: true,
            },
            false,
            'wizard/setSshGenerated',
          ),
        clearSshPrivateKey: () =>
          set({ sshPrivateKeyOnce: '' }, false, 'wizard/clearSshPrivateKey'),

        setAirgap: (airgap) => set({ airgap }, false, 'wizard/setAirgap'),

        setGroupComponents: (groupId, componentIds) =>
          set(
            (s) => ({ componentGroups: { ...s.componentGroups, [groupId]: componentIds } }),
            false,
            'wizard/setGroupComponents'
          ),
        setComponentsAppliedForProfile: (componentsAppliedForProfile) =>
          set({ componentsAppliedForProfile }, false, 'wizard/setComponentsAppliedForProfile'),
        toggleGroupComponent: (groupId, componentId, allIds) =>
          set(
            (s) => {
              const current = s.componentGroups[groupId] ?? []
              const next = current.includes(componentId)
                ? current.filter((id) => id !== componentId)
                : [...current, componentId]
              return { componentGroups: { ...s.componentGroups, [groupId]: allIds.filter((id) => next.includes(id)) } }
            },
            false,
            'wizard/toggleGroupComponent'
          ),
        toggleBlueprint: (blueprintId) =>
          set(
            (s) => ({
              selectedBlueprints: s.selectedBlueprints.includes(blueprintId)
                ? s.selectedBlueprints.filter((id) => id !== blueprintId)
                : [...s.selectedBlueprints, blueprintId],
            }),
            false,
            'wizard/toggleBlueprint'
          ),
        setSelectedBlueprints: (selectedBlueprints) =>
          set({ selectedBlueprints }, false, 'wizard/setSelectedBlueprints'),
        setLastProvisionResult: (lastProvisionResult) =>
          set({ lastProvisionResult }, false, 'wizard/setLastProvisionResult'),

        addRegion: (region) =>
          set((s) => ({ regions: [...s.regions, region] }), false, 'wizard/addRegion'),
        removeRegion: (id) =>
          set((s) => ({ regions: s.regions.filter((r) => r.id !== id) }), false, 'wizard/removeRegion'),
        setControlPlaneSize: (controlPlaneSize) => set({ controlPlaneSize }, false, 'wizard/setControlPlaneSize'),
        setWorkerSize: (workerSize) => set({ workerSize }, false, 'wizard/setWorkerSize'),
        setWorkerCount: (workerCount) => set({ workerCount }, false, 'wizard/setWorkerCount'),
        setHaEnabled: (haEnabled) => set({ haEnabled }, false, 'wizard/setHaEnabled'),

        // Marketplace mode — issue #710 wave 3a. Toggle persists via the
        // existing deploy-request → tofu var → cloud-init → Flux substitute
        // → chart render path; no ConfigMap shortcut.
        setMarketplaceEnabled: (marketplaceEnabled) =>
          set({ marketplaceEnabled }, false, 'wizard/setMarketplaceEnabled'),
        setMarketplaceBrand: (brand) =>
          set(
            (s) => ({ marketplaceBrand: { ...s.marketplaceBrand, ...brand } }),
            false,
            'wizard/setMarketplaceBrand',
          ),

        toggleComponent: (component) =>
          // Legacy action — kept for back-compat with any old call site that
          // hands a SelectedComponent record. Internally we just toggle the
          // id in the new string[] selectedComponents form.
          set(
            (s) => {
              const has = s.selectedComponents.includes(component.id)
              return {
                selectedComponents: has
                  ? s.selectedComponents.filter((id) => id !== component.id)
                  : [...s.selectedComponents, component.id].sort(),
              }
            },
            false,
            'wizard/toggleComponent'
          ),
        setComponents: (selectedComponents) =>
          // Legacy: accept either string[] or SelectedComponent[] and
          // normalise to string[]. Empty arrays come through unchanged.
          set(
            { selectedComponents: normaliseComponentIds(selectedComponents as unknown) },
            false,
            'wizard/setComponents',
          ),

        addComponent: (id) =>
          set(
            (s) => {
              if (s.selectedComponents.includes(id)) return s
              const comp = findComponent(id)
              if (!comp) return s
              const next = new Set(s.selectedComponents)
              next.add(id)
              for (const dep of resolveTransitiveDependencies(id)) {
                next.add(dep)
              }
              // Product-family cascade — issue #175 fix B. Walk only the
              // ids newly-added by this addComponent call (the seed plus
              // its component-level deps). For each, if its owning
              // product has `cascadeOnMemberSelection: true`, pull in
              // the rest of that product (and every product reached via
              // familyDependencies). Iterates so a chain like Specter →
              // BGE → CORTEX (BGE belongs to CORTEX, which cascades the
              // rest of CORTEX) resolves in one pass.
              //
              // Restricting to NEWLY-added ids avoids spurious cascades
              // from mandatory cross-product members that were already
              // selected — e.g. KServe is mandatory in CORTEX, so a
              // pre-existing selection containing KServe must NOT cause
              // every subsequent addComponent call to drag CORTEX in.
              //
              // Per operator intent (issue #175) CORTEX is the only
              // product flagged for member-selection cascade today: "BGE
              // alone doesn't have much meaning unless we have Cortex.
              // [...] Or when Specter is selected all the Cortex family
              // is required."
              const before = new Set(s.selectedComponents)
              const queue: string[] = [...next].filter((cid) => !before.has(cid))
              while (queue.length > 0) {
                const cid = queue.shift()!
                const owner = productByComponent(cid)
                if (
                  owner &&
                  owner.tier !== 'mandatory' &&
                  owner.cascadeOnMemberSelection
                ) {
                  for (const member of resolveProductComponentClosure(owner.id)) {
                    if (!next.has(member)) {
                      next.add(member)
                      // Newly-added — recurse so its product (if any
                      // with cascadeOnMemberSelection) triggers too.
                      queue.push(member)
                    }
                  }
                }
              }
              return { selectedComponents: [...next].sort() }
            },
            false,
            'wizard/addComponent',
          ),

        addProduct: (productId) =>
          set(
            (s) => {
              const p = findProduct(productId)
              if (!p) return s
              const next = new Set(s.selectedComponents)
              for (const cid of resolveProductComponentClosure(productId)) {
                next.add(cid)
              }
              return { selectedComponents: [...next].sort() }
            },
            false,
            'wizard/addProduct',
          ),

        removeProduct: (productId) =>
          set(
            (s) => {
              const p = findProduct(productId)
              if (!p) return s
              const drop = new Set<string>()
              for (const c of componentsByProduct(productId)) {
                if (c.tier === 'mandatory') continue
                drop.add(c.id)
                // Cascade — anything that depends on this component must
                // also leave (mirrors removeComponent behavior).
                for (const dep of resolveTransitiveDependents(c.id)) {
                  if (!isMandatoryComponent(dep)) drop.add(dep)
                }
              }
              if (drop.size === 0) return s
              return {
                selectedComponents: s.selectedComponents.filter((cid) => !drop.has(cid)).sort(),
              }
            },
            false,
            'wizard/removeProduct',
          ),

        removeComponent: (id) =>
          set(
            (s) => {
              if (isMandatoryComponent(id)) return s
              if (!s.selectedComponents.includes(id)) return s
              const drop = new Set<string>([id, ...resolveTransitiveDependents(id)])
              // Mandatory deps cannot be removed even via cascade — guard.
              for (const d of [...drop]) {
                if (isMandatoryComponent(d) && d !== id) drop.delete(d)
              }
              return {
                selectedComponents: s.selectedComponents.filter((cid) => !drop.has(cid)).sort(),
              }
            },
            false,
            'wizard/removeComponent',
          ),

        setSelectedComponents: (ids) =>
          set(
            { selectedComponents: normaliseComponentIds(ids) },
            false,
            'wizard/setSelectedComponents',
          ),

        resetSelectedComponentsToDefault: () =>
          set(
            { selectedComponents: computeInitialComponentSelection() },
            false,
            'wizard/resetSelectedComponentsToDefault',
          ),
      }),
      {
        name: 'openova-catalyst-wizard',
        // Per credential hygiene (docs/INVIOLABLE-PRINCIPLES.md #10), the
        // private key from /api/v1/sshkey/generate is held in memory ONLY
        // for the duration of the StepCredentials view. We strip it from
        // anything that gets serialized into localStorage so a casual
        // browser-storage inspection (or a stolen device snapshot) cannot
        // recover the operator's break-glass key. The session flag is
        // dropped for the same reason — a fresh tab should always re-prompt
        // the user, not assume "I downloaded the .pem already" from a prior
        // session.
        partialize: (state) => {
          // Per docs/INVIOLABLE-PRINCIPLES.md #10 (credential hygiene):
          //   • sshPrivateKeyOnce — Mode-A break-glass private key
          //   • registrarToken    — BYO Flow B registrar API credential
          // Both are in-memory only. registrarTokenValidated drops too —
          // a fresh tab must re-prove the token even if the password
          // manager re-fills the field.
          const {
            sshPrivateKeyOnce: _omitPriv,
            sshKeyGeneratedThisSession: _omitGenFlag,
            registrarToken: _omitTok,
            registrarTokenValidated: _omitTokFlag,
            ...rest
          } = state
          void _omitPriv; void _omitGenFlag; void _omitTok; void _omitTokFlag
          return rest as unknown as WizardState
        },
        // Merge saved state with initial — handles new fields added after first install
        merge: (persisted, current) => {
          const p = { ...(persisted as Partial<WizardState>) }
          // #169 — legacy 'byo' value from earlier wizard runs maps to
          // 'byo-manual'. Anything outside the new vocabulary falls back
          // to 'pool' (safer than carrying a corrupted value forward).
          const validModes: import('./model').DomainMode[] = ['pool', 'byo-manual', 'byo-api']
          const persistedMode = (p as { sovereignDomainMode?: string }).sovereignDomainMode
          if (persistedMode === 'byo') {
            p.sovereignDomainMode = 'byo-manual'
          } else if (persistedMode && !validModes.includes(persistedMode as import('./model').DomainMode)) {
            p.sovereignDomainMode = 'pool'
          }
          // Always start with cleared registrar credentials — partialize()
          // already strips them, this double-protects an older payload that
          // accidentally retained them.
          p.registrarType = (p.registrarType ?? null) as import('./model').RegistrarType | null
          p.registrarToken = ''
          p.registrarTokenValidated = false
          // Sanitize stale topology values
          const validTopologies: TopologyTemplate[] = ['citadel', 'triangle', 'dual', 'zoned', 'compact', 'solo']
          if (p.topology && !validTopologies.includes(p.topology)) {
            p.topology = null
            p.regionProviders = {}
            p.regionCloudRegions = {}
            p.regionControlPlaneSizes = []
            p.regionWorkerSizes = []
            p.regionWorkerCounts = []
            p.providerValidated = {}
            p.providerTokens = {}
          }
          // Per-region sizing migration. A persist payload from before the
          // per-region rework only has the singular controlPlaneSize /
          // workerSize / workerCount fields. Hydrate the arrays from those
          // for index 0 so a returning user sees their previous selection
          // applied to Region 1 — the rest of the regions stay empty until
          // the user revisits the provider step. Any cx22 default (the old
          // hetzner literal that was the legacy initial value) is treated
          // as "no selection yet" so we don't carry a hetzner-only id into
          // a chosen Azure region.
          {
            const legacyCp = (p as { controlPlaneSize?: string }).controlPlaneSize
            const legacyWk = (p as { workerSize?: string }).workerSize
            const legacyWc = (p as { workerCount?: number }).workerCount
            const validLegacy = (id: string | undefined) =>
              typeof id === 'string' && id !== '' && id !== 'cx22'
            if (!Array.isArray(p.regionControlPlaneSizes)) {
              p.regionControlPlaneSizes = validLegacy(legacyCp) ? [legacyCp as string] : []
            }
            if (!Array.isArray(p.regionWorkerSizes)) {
              p.regionWorkerSizes = validLegacy(legacyWk) ? [legacyWk as string] : []
            }
            if (!Array.isArray(p.regionWorkerCounts)) {
              p.regionWorkerCounts = typeof legacyWc === 'number' ? [legacyWc] : []
            }
          }
          // Coerce legacy persist payloads that lack new fields. Without
          // these guards, the store returns `undefined` and toggleBlueprint
          // (etc.) crashes on the .includes() call.
          if (!Array.isArray(p.selectedBlueprints)) {
            p.selectedBlueprints = []
          }
          if (p.lastProvisionResult === undefined) {
            p.lastProvisionResult = null
          }
          // Marketplace fields added in #710 wave 3a — coerce missing
          // values on a legacy persisted payload so StepMarketplace and
          // StepReview never crash on undefined.
          if (typeof p.marketplaceEnabled !== 'boolean') {
            p.marketplaceEnabled = false
          }
          if (
            !p.marketplaceBrand ||
            typeof p.marketplaceBrand !== 'object'
          ) {
            p.marketplaceBrand = { name: '', tagline: '', primaryColor: '' }
          } else {
            p.marketplaceBrand = {
              name: typeof p.marketplaceBrand.name === 'string' ? p.marketplaceBrand.name : '',
              tagline: typeof p.marketplaceBrand.tagline === 'string' ? p.marketplaceBrand.tagline : '',
              primaryColor: typeof p.marketplaceBrand.primaryColor === 'string' ? p.marketplaceBrand.primaryColor : '',
            }
          }
          // SSH-key fields added after first install (#160) — coerce missing
          // values so the StepCredentials SSH section renders cleanly on a
          // legacy persisted payload.
          if (typeof p.sshPublicKey !== 'string') p.sshPublicKey = ''
          if (typeof p.sshFingerprint !== 'string') p.sshFingerprint = ''
          // Hetzner Object Storage fields added in #371 — coerce missing
          // values on a legacy persisted payload so the StepCredentials
          // ObjectStorageSection renders without throwing on undefined.
          if (typeof p.objectStorageRegion !== 'string') p.objectStorageRegion = ''
          if (typeof p.objectStorageAccessKey !== 'string') p.objectStorageAccessKey = ''
          if (typeof p.objectStorageSecretKey !== 'string') p.objectStorageSecretKey = ''
          if (typeof p.objectStorageValidated !== 'boolean') p.objectStorageValidated = false
          // Always start a session with no private blob and no "generated
          // this session" flag — partialize() omits them on save, this
          // double-protects an older persist payload that may have
          // accidentally retained them.
          p.sshPrivateKeyOnce = ''
          p.sshKeyGeneratedThisSession = false
          // Coerce selectedComponents from any legacy shape (SelectedComponent[]
          // records, undefined) to a sorted string[]. Always ensure mandatory
          // components are present — they cannot be opted out of.
          {
            const ids = normaliseComponentIds(p.selectedComponents as unknown)
            const present = new Set(ids)
            // First-run fallback: if persisted state is empty, seed with
            // default selection (all mandatory + all recommended + deps).
            if (ids.length === 0) {
              for (const id of computeInitialComponentSelection()) present.add(id)
            } else {
              for (const id of MANDATORY_COMPONENT_IDS) present.add(id)
            }
            // Drop any persisted ids that no longer exist in the catalog.
            for (const id of [...present]) {
              if (!findComponent(id)) present.delete(id)
            }
            p.selectedComponents = [...present].sort()
          }
          // Re-validate the persisted deploymentId against the branded
          // shape (issues #749 + #754). A legacy localStorage payload
          // could carry a malformed value (truncated id, uppercased,
          // legacy uuid shape) — drop it rather than re-pollute the
          // active session. Falls back to null so the wizard's
          // first-run state is the same as a fresh install.
          {
            const persistedId = (p as { deploymentId?: unknown }).deploymentId
            if (persistedId == null) {
              p.deploymentId = null
            } else {
              try {
                p.deploymentId = parseDeploymentID(persistedId)
              } catch {
                // Bad payload — wipe it. Better to lose a stale id
                // than to render the misleading 15-char error this
                // PR exists to prevent.
                p.deploymentId = null
              }
            }
          }
          // Strip old component group IDs — replaced by pilot/spine/surge/silo/guardian/insights/fabric/cortex/relay
          const validGroupIds = ['pilot','spine','surge','silo','guardian','insights','fabric','cortex','relay']
          if (p.componentGroups) {
            const migrated: Record<string, string[]> = {}
            for (const [k, v] of Object.entries(p.componentGroups)) {
              if (validGroupIds.includes(k)) {
                // Migrate minio → seaweedfs
                migrated[k] = (v as string[]).map(id => id === 'minio' ? 'seaweedfs' : id)
              }
            }
            // Ensure vcluster is in pilot defaults
            if (migrated.pilot && !migrated.pilot.includes('vcluster')) {
              migrated.pilot = [...migrated.pilot, 'vcluster']
            }
            p.componentGroups = migrated
          }
          return { ...current, ...p }
        },
      }
    ),
    { name: 'CatalystWizard' }
  )
)
