import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

import {
  isDetail,
  type ActiveProduct,
  type BacklogPbi,
  type BacklogStory,
  type BacklogTask,
  type CrossSprintBlock,
  type OptimisticMutation,
  type PbiSummaryEntry,
  type PendingOptimisticMutation,
  type ProductBacklogSnapshot,
  type ProductRealtimeEvent,
  type RealtimeStatus,
  type ResyncReason,
  type SprintMembershipSlice,
  type TaskDetail,
} from './types'
import {
  readHints,
  writePbiHint,
  writeProductHint,
  writeStoryHint,
  writeTaskHint,
} from './restore'
import {
  normalizeBacklogStory,
  normalizeBacklogTask,
  normalizeProductBacklogSnapshot,
  normalizePbiStatusForStore,
  normalizeStoryStatusForStore,
  normalizeTaskStatusForStore,
} from '@/stores/workspace-status-adapter'

interface ContextSlice {
  activeProduct: ActiveProduct | null
  activePbiId: string | null
  activeStoryId: string | null
  activeTaskId: string | null
}

interface EntitiesSlice {
  pbisById: Record<string, BacklogPbi>
  storiesById: Record<string, BacklogStory>
  tasksById: Record<string, BacklogTask | TaskDetail>
}

interface RelationsSlice {
  pbiIds: string[]
  storyIdsByPbi: Record<string, string[]>
  taskIdsByStory: Record<string, string[]>
}

interface LoadingSlice {
  loadedProductId: string | null
  loadingProductId: string | null
  loadedPbiIds: Record<string, true>
  loadedStoryIds: Record<string, true>
  loadedTaskIds: Record<string, true>
  activeRequestId: string | null
}

interface SyncSlice {
  realtimeStatus: RealtimeStatus
  lastEventAt: number | null
  lastResyncAt: number | null
  resyncReason: ResyncReason | null
}

interface State {
  context: ContextSlice
  entities: EntitiesSlice
  relations: RelationsSlice
  loading: LoadingSlice
  sync: SyncSlice
  pendingMutations: Record<string, PendingOptimisticMutation>
  sprintMembership: SprintMembershipSlice
}

interface Actions {
  hydrateSnapshot(snapshot: ProductBacklogSnapshot): void

  setActiveProduct(
    product: ActiveProduct | null,
    options?: { load?: boolean; preserveSelection?: boolean },
  ): void
  setActivePbi(pbiId: string | null): void
  setActiveStory(storyId: string | null): void
  setActiveTask(taskId: string | null): void

  ensureProductLoaded(productId: string, requestId?: string): Promise<void>
  ensurePbiLoaded(pbiId: string, requestId?: string): Promise<void>
  ensureStoryLoaded(storyId: string, requestId?: string): Promise<void>
  ensureTaskLoaded(taskId: string, requestId?: string): Promise<void>

  applyRealtimeEvent(event: ProductRealtimeEvent | Record<string, unknown>): void
  resyncActiveScopes(reason: ResyncReason): Promise<void>
  resyncLoadedScopes(reason: ResyncReason): Promise<void>

  applyOptimisticMutation(mutation: OptimisticMutation): string
  rollbackMutation(mutationId: string): void
  settleMutation(mutationId: string): void

  setRealtimeStatus(status: RealtimeStatus): void

  // PBI-79 / ST-1336: sprint-membership acties.
  setPbiSummary(summary: Record<string, PbiSummaryEntry>): void
  setCrossSprintBlocks(blocks: Record<string, CrossSprintBlock>): void
  toggleStorySprintMembership(storyId: string, currentlyInSprint: boolean): void
  resetSprintMembershipPending(): void
  fetchSprintMembershipSummary(
    productId: string,
    sprintId: string,
    pbiIds: string[],
  ): Promise<void>
  fetchCrossSprintBlocks(
    productId: string,
    excludeSprintId: string | null,
    pbiIds: string[],
  ): Promise<void>

  // PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in
  // de client-store hebben geen sprint_id-veld dus alleen story-records
  // worden gemuteerd.
  applyMembershipCommitResult(input: {
    activeSprintId: string
    addedStoryIds: string[]
    removedStoryIds: string[]
  }): void
}

export type ProductWorkspaceStore = State & Actions

const initialState: State = {
  context: {
    activeProduct: null,
    activePbiId: null,
    activeStoryId: null,
    activeTaskId: null,
  },
  entities: {
    pbisById: {},
    storiesById: {},
    tasksById: {},
  },
  relations: {
    pbiIds: [],
    storyIdsByPbi: {},
    taskIdsByStory: {},
  },
  loading: {
    loadedProductId: null,
    loadingProductId: null,
    loadedPbiIds: {},
    loadedStoryIds: {},
    loadedTaskIds: {},
    activeRequestId: null,
  },
  sync: {
    realtimeStatus: 'connecting',
    lastEventAt: null,
    lastResyncAt: null,
    resyncReason: null,
  },
  pendingMutations: {},
  sprintMembership: {
    pbiSummary: {},
    crossSprintBlocks: {},
    pending: { adds: [], removes: [] },
    loadedSummaryForSprintId: null,
  },
}

function comparePbi(a: BacklogPbi, b: BacklogPbi): number {
  if (a.priority !== b.priority) return a.priority - b.priority
  if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
  return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
}

function compareStory(a: BacklogStory, b: BacklogStory): number {
  if (a.priority !== b.priority) return a.priority - b.priority
  if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
  return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
}

function compareTask(a: BacklogTask, b: BacklogTask): number {
  if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
  return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
}

function newRequestId(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID()
  }
  return `${Date.now()}-${Math.random().toString(36).slice(2)}`
}

function isKnownEntity(entity: unknown): entity is 'pbi' | 'story' | 'task' {
  return entity === 'pbi' || entity === 'story' || entity === 'task'
}

function isUnknownEntityEvent(p: Record<string, unknown>): boolean {
  if (typeof p.entity !== 'string') return false
  if (isKnownEntity(p.entity)) return false
  if ('type' in p) return false
  return true
}

async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
  const response = await fetch(url, { cache: 'no-store', ...init })
  if (!response.ok) {
    throw new Error(`Fetch ${url} failed with ${response.status}`)
  }
  return (await response.json()) as T
}

export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
  immer((set, get) => ({
    ...initialState,

    hydrateSnapshot(inputSnapshot) {
      const snapshot = normalizeProductBacklogSnapshot(inputSnapshot)
      set((s) => {
        if (snapshot.product) s.context.activeProduct = snapshot.product

        s.entities.pbisById = {}
        s.entities.storiesById = {}
        s.entities.tasksById = {}
        s.relations.pbiIds = []
        s.sprintMembership = {
          pbiSummary: {},
          crossSprintBlocks: {},
          pending: { adds: [], removes: [] },
          loadedSummaryForSprintId: null,
        }
        s.relations.storyIdsByPbi = {}
        s.relations.taskIdsByStory = {}

        for (const pbi of snapshot.pbis) {
          s.entities.pbisById[pbi.id] = pbi
        }
        s.relations.pbiIds = [...snapshot.pbis].sort(comparePbi).map((p) => p.id)

        for (const [pbiId, stories] of Object.entries(snapshot.storiesByPbi)) {
          for (const story of stories) {
            s.entities.storiesById[story.id] = story
          }
          s.relations.storyIdsByPbi[pbiId] = [...stories]
            .sort(compareStory)
            .map((st) => st.id)
        }

        for (const [storyId, tasks] of Object.entries(snapshot.tasksByStory)) {
          for (const task of tasks) {
            s.entities.tasksById[task.id] = task
          }
          s.relations.taskIdsByStory[storyId] = [...tasks]
            .sort(compareTask)
            .map((t) => t.id)
        }

        if (snapshot.product) {
          s.loading.loadedProductId = snapshot.product.id
        }
      })
    },

    setActiveProduct(product, options) {
      const requestId = newRequestId()
      const productChanged = get().context.activeProduct?.id !== product?.id
      const shouldResetSelection = productChanged || !options?.preserveSelection

      set((s) => {
        s.context.activeProduct = product
        if (shouldResetSelection) {
          s.context.activePbiId = null
          s.context.activeStoryId = null
          s.context.activeTaskId = null
        }
        s.loading.activeRequestId = requestId

        if (productChanged) {
          s.entities.pbisById = {}
          s.entities.storiesById = {}
          s.entities.tasksById = {}
          s.relations.pbiIds = []
          s.relations.storyIdsByPbi = {}
          s.relations.taskIdsByStory = {}
          s.loading.loadedProductId = null
          s.loading.loadedPbiIds = {}
          s.loading.loadedStoryIds = {}
          s.loading.loadedTaskIds = {}
        }
      })

      // T-858: persisteer product-hint zodat een volgende cold reload deze
      // selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded.
      writeProductHint(product?.id ?? null)

      if (product && options?.load !== false) {
        const productId = product.id
        void (async () => {
          await get().ensureProductLoaded(productId, requestId)
          if (get().loading.activeRequestId !== requestId) return
          // T-857: cascade-restore — alleen toepassen als hint-id nog in
          // entities zit (entiteit accessible).
          const hint = readHints().perProduct[productId]?.lastActivePbiId
          if (hint && get().entities.pbisById[hint]) {
            get().setActivePbi(hint)
          }
        })()
      }
    },

    setActivePbi(pbiId) {
      const requestId = newRequestId()
      const productId = get().context.activeProduct?.id ?? null

      set((s) => {
        s.context.activePbiId = pbiId
        s.context.activeStoryId = null
        s.context.activeTaskId = null
        s.loading.activeRequestId = requestId
      })

      // T-858: persisteer pbi-hint per product. null wist child-hints (zie
      // restore.ts writePbiHint).
      if (productId) writePbiHint(productId, pbiId)

      if (pbiId) {
        void (async () => {
          await get().ensurePbiLoaded(pbiId, requestId)
          if (get().loading.activeRequestId !== requestId) return
          if (!productId) return
          // T-857: cascade-restore. Alleen herstellen als de hint-story
          // bij de nieuw-geselecteerde PBI hoort — anders blijft een task-
          // selectie van een vorige PBI hangen (PBI-79 bugfix).
          const hint = readHints().perProduct[productId]?.lastActiveStoryId
          if (hint) {
            const hintStory = get().entities.storiesById[hint]
            if (hintStory && hintStory.pbi_id === pbiId) {
              get().setActiveStory(hint)
            }
          }
        })()
      }
    },

    setActiveStory(storyId) {
      const requestId = newRequestId()
      const productId = get().context.activeProduct?.id ?? null

      set((s) => {
        s.context.activeStoryId = storyId
        s.context.activeTaskId = null
        s.loading.activeRequestId = requestId
      })

      if (productId) writeStoryHint(productId, storyId)

      if (storyId) {
        void (async () => {
          await get().ensureStoryLoaded(storyId, requestId)
          if (get().loading.activeRequestId !== requestId) return
          if (!productId) return
          const hint = readHints().perProduct[productId]?.lastActiveTaskId
          if (hint && get().entities.tasksById[hint]) {
            get().setActiveTask(hint)
          }
        })()
      }
    },

    setActiveTask(taskId) {
      const productId = get().context.activeProduct?.id ?? null

      set((s) => {
        s.context.activeTaskId = taskId
      })

      if (productId) writeTaskHint(productId, taskId)

      if (taskId) {
        void get().ensureTaskLoaded(taskId)
      }
    },

    async ensureProductLoaded(productId, requestId) {
      set((s) => {
        s.loading.loadingProductId = productId
      })
      try {
        const snapshot = await fetchJson<ProductBacklogSnapshot | null>(
          `/api/products/${encodeURIComponent(productId)}/backlog`,
        )
        if (requestId && get().loading.activeRequestId !== requestId) return
        if (!snapshot || !Array.isArray(snapshot.pbis)) return
        get().hydrateSnapshot(snapshot)
        set((s) => {
          s.loading.loadedProductId = productId
          for (const pbi of snapshot.pbis) {
            s.loading.loadedPbiIds[pbi.id] = true
          }
        })
      } finally {
        set((s) => {
          if (s.loading.loadingProductId === productId) {
            s.loading.loadingProductId = null
          }
        })
      }
    },

    async ensurePbiLoaded(pbiId, requestId) {
      const stories = await fetchJson<BacklogStory[] | null>(
        `/api/pbis/${encodeURIComponent(pbiId)}/stories`,
      )
      if (requestId && get().loading.activeRequestId !== requestId) return
      if (!Array.isArray(stories)) return
      const normalizedStories = stories.map(normalizeBacklogStory)
      set((s) => {
        for (const story of normalizedStories) {
          s.entities.storiesById[story.id] = story
        }
        s.relations.storyIdsByPbi[pbiId] = [...normalizedStories]
          .sort(compareStory)
          .map((st) => st.id)
        s.loading.loadedPbiIds[pbiId] = true
      })
    },

    async ensureStoryLoaded(storyId, requestId) {
      const tasks = await fetchJson<BacklogTask[] | null>(
        `/api/stories/${encodeURIComponent(storyId)}/tasks`,
      )
      if (requestId && get().loading.activeRequestId !== requestId) return
      if (!Array.isArray(tasks)) return
      const normalizedTasks = tasks.map(normalizeBacklogTask)
      set((s) => {
        for (const task of normalizedTasks) {
          const existing = s.entities.tasksById[task.id]
          if (existing && isDetail(existing)) {
            s.entities.tasksById[task.id] = { ...existing, ...task }
          } else {
            s.entities.tasksById[task.id] = task
          }
        }
        s.relations.taskIdsByStory[storyId] = [...normalizedTasks]
          .sort(compareTask)
          .map((t) => t.id)
        s.loading.loadedStoryIds[storyId] = true
      })
    },

    async ensureTaskLoaded(taskId, requestId) {
      const detail = await fetchJson<TaskDetail | null>(
        `/api/tasks/${encodeURIComponent(taskId)}`,
      )
      if (requestId && get().loading.activeRequestId !== requestId) return
      if (!detail || typeof detail !== 'object') return
      const normalizedDetail = normalizeBacklogTask(detail)
      set((s) => {
        s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true }
        s.loading.loadedTaskIds[taskId] = true
      })
    },

    applyRealtimeEvent(event) {
      const payload = event as Record<string, unknown>
      const activeProductId = get().context.activeProduct?.id ?? null

      set((s) => {
        s.sync.lastEventAt = Date.now()
      })

      if (
        typeof payload.product_id === 'string' &&
        activeProductId &&
        payload.product_id !== activeProductId
      ) {
        return
      }

      if (isUnknownEntityEvent(payload)) {
        if (payload.product_id === activeProductId) {
          void get().resyncActiveScopes('unknown-event')
        }
        return
      }

      const entity = payload.entity
      const op = payload.op
      if (!isKnownEntity(entity)) return
      if (op !== 'I' && op !== 'U' && op !== 'D') return

      const id = payload.id
      if (typeof id !== 'string') return

      if (entity === 'pbi') {
        applyPbiEvent(id, op, payload, set, get)
      } else if (entity === 'story') {
        applyStoryEvent(id, op, payload, set, get)
      } else if (entity === 'task') {
        applyTaskEvent(id, op, payload, set, get)
      }
    },

    async resyncActiveScopes(reason) {
      const ctx = get().context
      const tasks: Promise<void>[] = []
      if (ctx.activeProduct?.id) {
        tasks.push(get().ensureProductLoaded(ctx.activeProduct.id))
      }
      if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId))
      if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId))
      if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId))
      set((s) => {
        s.sync.lastResyncAt = Date.now()
        s.sync.resyncReason = reason
      })
      await Promise.allSettled(tasks)
    },

    async resyncLoadedScopes(reason) {
      const loading = get().loading
      const tasks: Promise<void>[] = []
      if (loading.loadedProductId) {
        tasks.push(get().ensureProductLoaded(loading.loadedProductId))
      }
      for (const pbiId of Object.keys(loading.loadedPbiIds)) {
        tasks.push(get().ensurePbiLoaded(pbiId))
      }
      for (const storyId of Object.keys(loading.loadedStoryIds)) {
        tasks.push(get().ensureStoryLoaded(storyId))
      }
      for (const taskId of Object.keys(loading.loadedTaskIds)) {
        tasks.push(get().ensureTaskLoaded(taskId))
      }
      set((s) => {
        s.sync.lastResyncAt = Date.now()
        s.sync.resyncReason = reason
      })
      await Promise.allSettled(tasks)
    },

    applyOptimisticMutation(mutation) {
      const id = newRequestId()
      set((s) => {
        s.pendingMutations[id] = {
          id,
          mutation,
          createdAt: Date.now(),
        }
        switch (mutation.kind) {
          case 'pbi-order':
            // store-call passes new order via separate set, snapshot is prevPbiIds
            break
          case 'entity-patch':
            break
        }
      })
      return id
    },

    rollbackMutation(mutationId) {
      const pending = get().pendingMutations[mutationId]
      if (!pending) return
      const { mutation } = pending
      set((s) => {
        switch (mutation.kind) {
          case 'pbi-order':
            s.relations.pbiIds = [...mutation.prevPbiIds]
            break
          case 'entity-patch': {
            const { entity, id, prev } = mutation
            if (prev) {
              if (entity === 'pbi') s.entities.pbisById[id] = prev as BacklogPbi
              else if (entity === 'story') s.entities.storiesById[id] = prev as BacklogStory
              else s.entities.tasksById[id] = prev as BacklogTask | TaskDetail
            } else {
              if (entity === 'pbi') delete s.entities.pbisById[id]
              else if (entity === 'story') delete s.entities.storiesById[id]
              else delete s.entities.tasksById[id]
            }
            break
          }
        }
        delete s.pendingMutations[mutationId]
      })
    },

    settleMutation(mutationId) {
      set((s) => {
        delete s.pendingMutations[mutationId]
      })
    },

    setRealtimeStatus(status) {
      set((s) => {
        s.sync.realtimeStatus = status
      })
    },

    setPbiSummary(summary) {
      set((s) => {
        s.sprintMembership.pbiSummary = summary
      })
    },

    setCrossSprintBlocks(blocks) {
      set((s) => {
        s.sprintMembership.crossSprintBlocks = blocks
      })
    },

    toggleStorySprintMembership(storyId, currentlyInSprint) {
      set((s) => {
        const pending = s.sprintMembership.pending
        if (currentlyInSprint) {
          const inRemoves = pending.removes.indexOf(storyId)
          if (inRemoves >= 0) {
            pending.removes.splice(inRemoves, 1)
          } else {
            const inAdds = pending.adds.indexOf(storyId)
            if (inAdds >= 0) pending.adds.splice(inAdds, 1)
            pending.removes.push(storyId)
          }
        } else {
          const inAdds = pending.adds.indexOf(storyId)
          if (inAdds >= 0) {
            pending.adds.splice(inAdds, 1)
          } else {
            const inRemoves = pending.removes.indexOf(storyId)
            if (inRemoves >= 0) pending.removes.splice(inRemoves, 1)
            pending.adds.push(storyId)
          }
        }
      })
    },

    resetSprintMembershipPending() {
      set((s) => {
        s.sprintMembership.pending = { adds: [], removes: [] }
      })
    },

    async fetchSprintMembershipSummary(productId, sprintId, pbiIds) {
      if (pbiIds.length === 0) return
      const url = `/api/products/${productId}/sprint-membership-summary?sprintId=${encodeURIComponent(sprintId)}&pbiIds=${pbiIds.map(encodeURIComponent).join(',')}`
      const summary = await fetchJson<Record<string, PbiSummaryEntry>>(url)
      set((s) => {
        for (const [pbiId, entry] of Object.entries(summary)) {
          s.sprintMembership.pbiSummary[pbiId] = entry
        }
        s.sprintMembership.loadedSummaryForSprintId = sprintId
      })
    },

    async fetchCrossSprintBlocks(productId, excludeSprintId, pbiIds) {
      if (pbiIds.length === 0) return
      const params = new URLSearchParams()
      if (excludeSprintId) params.set('excludeSprintId', excludeSprintId)
      params.set('pbiIds', pbiIds.join(','))
      const url = `/api/products/${productId}/cross-sprint-blocks?${params.toString()}`
      const blocks = await fetchJson<Record<string, CrossSprintBlock>>(url)
      set((s) => {
        for (const [storyId, info] of Object.entries(blocks)) {
          s.sprintMembership.crossSprintBlocks[storyId] = info
        }
      })
    },

    applyMembershipCommitResult({
      activeSprintId,
      addedStoryIds,
      removedStoryIds,
    }) {
      // Task-records in de client-store hebben geen sprint_id-veld (alleen
      // story_id); de sprint-membership wordt afgeleid via story.sprint_id.
      // Hier patchen we daarom alleen story-entities + de pending buffer.
      set((s) => {
        for (const id of addedStoryIds) {
          const story = s.entities.storiesById[id]
          if (story) {
            story.sprint_id = activeSprintId
            story.status = 'IN_SPRINT'
          }
        }
        for (const id of removedStoryIds) {
          const story = s.entities.storiesById[id]
          if (story) {
            story.sprint_id = null
            story.status = 'OPEN'
          }
        }
        s.sprintMembership.pending = { adds: [], removes: [] }
      })
    },
  })),
)

type ImmerSet = Parameters<Parameters<typeof immer<ProductWorkspaceStore>>[0]>[0]
type ImmerGet = () => ProductWorkspaceStore

function applyPbiEvent(
  id: string,
  op: 'I' | 'U' | 'D',
  payload: Record<string, unknown>,
  set: ImmerSet,
  get: ImmerGet,
) {
  if (op === 'D') {
    set((s) => {
      const childStoryIds = s.relations.storyIdsByPbi[id] ?? []
      for (const sid of childStoryIds) {
        const childTaskIds = s.relations.taskIdsByStory[sid] ?? []
        for (const tid of childTaskIds) {
          delete s.entities.tasksById[tid]
        }
        delete s.relations.taskIdsByStory[sid]
        delete s.entities.storiesById[sid]
      }
      delete s.relations.storyIdsByPbi[id]
      delete s.entities.pbisById[id]
      s.relations.pbiIds = s.relations.pbiIds.filter((p) => p !== id)
      if (s.context.activePbiId === id) {
        s.context.activePbiId = null
        s.context.activeStoryId = null
        s.context.activeTaskId = null
      }
    })
    return
  }

  if (op === 'U') {
    if (!get().entities.pbisById[id]) return
    set((s) => {
      const existing = s.entities.pbisById[id]
      if (!existing) return
      Object.assign(existing, sanitizePbiPayload(payload))
      s.relations.pbiIds = sortPbiIds(s.entities.pbisById, s.relations.pbiIds)
    })
    return
  }

  // I
  if (get().entities.pbisById[id]) return
  set((s) => {
    const pbi = coercePbiPayload(id, payload)
    s.entities.pbisById[id] = pbi
    s.relations.pbiIds.push(id)
    s.relations.pbiIds = sortPbiIds(s.entities.pbisById, s.relations.pbiIds)
  })
}

function applyStoryEvent(
  id: string,
  op: 'I' | 'U' | 'D',
  payload: Record<string, unknown>,
  set: ImmerSet,
  get: ImmerGet,
) {
  if (op === 'D') {
    set((s) => {
      const childTaskIds = s.relations.taskIdsByStory[id] ?? []
      for (const tid of childTaskIds) {
        delete s.entities.tasksById[tid]
      }
      delete s.relations.taskIdsByStory[id]
      const story = s.entities.storiesById[id]
      delete s.entities.storiesById[id]
      if (story) {
        const ids = s.relations.storyIdsByPbi[story.pbi_id]
        if (ids) {
          s.relations.storyIdsByPbi[story.pbi_id] = ids.filter((sid) => sid !== id)
        }
      } else {
        for (const pbiId of Object.keys(s.relations.storyIdsByPbi)) {
          s.relations.storyIdsByPbi[pbiId] = s.relations.storyIdsByPbi[pbiId].filter(
            (sid) => sid !== id,
          )
        }
      }
      if (s.context.activeStoryId === id) {
        s.context.activeStoryId = null
        s.context.activeTaskId = null
      }
    })
    return
  }

  if (op === 'U') {
    const existing = get().entities.storiesById[id]
    if (!existing) return
    set((s) => {
      const story = s.entities.storiesById[id]
      if (!story) return
      const oldPbiId = story.pbi_id
      Object.assign(story, sanitizeStoryPayload(payload))
      const newPbiId = story.pbi_id
      if (oldPbiId !== newPbiId) {
        const oldList = s.relations.storyIdsByPbi[oldPbiId]
        if (oldList) {
          s.relations.storyIdsByPbi[oldPbiId] = oldList.filter((sid) => sid !== id)
        }
        const targetList = s.relations.storyIdsByPbi[newPbiId] ?? []
        if (!targetList.includes(id)) targetList.push(id)
        s.relations.storyIdsByPbi[newPbiId] = sortStoryIds(s.entities.storiesById, targetList)
      } else if (s.relations.storyIdsByPbi[oldPbiId]) {
        s.relations.storyIdsByPbi[oldPbiId] = sortStoryIds(
          s.entities.storiesById,
          s.relations.storyIdsByPbi[oldPbiId],
        )
      }
    })
    return
  }

  // I
  if (get().entities.storiesById[id]) return
  set((s) => {
    const story = coerceStoryPayload(id, payload)
    s.entities.storiesById[id] = story
    const list = s.relations.storyIdsByPbi[story.pbi_id] ?? []
    list.push(id)
    s.relations.storyIdsByPbi[story.pbi_id] = sortStoryIds(s.entities.storiesById, list)
  })
}

function applyTaskEvent(
  id: string,
  op: 'I' | 'U' | 'D',
  payload: Record<string, unknown>,
  set: ImmerSet,
  get: ImmerGet,
) {
  if (op === 'D') {
    set((s) => {
      const task = s.entities.tasksById[id]
      delete s.entities.tasksById[id]
      if (task) {
        const ids = s.relations.taskIdsByStory[task.story_id]
        if (ids) {
          s.relations.taskIdsByStory[task.story_id] = ids.filter((tid) => tid !== id)
        }
      } else {
        for (const storyId of Object.keys(s.relations.taskIdsByStory)) {
          s.relations.taskIdsByStory[storyId] = s.relations.taskIdsByStory[storyId].filter(
            (tid) => tid !== id,
          )
        }
      }
      if (s.context.activeTaskId === id) {
        s.context.activeTaskId = null
      }
    })
    return
  }

  if (op === 'U') {
    const existing = get().entities.tasksById[id]
    if (!existing) return
    set((s) => {
      const task = s.entities.tasksById[id]
      if (!task) return
      const oldStoryId = task.story_id
      Object.assign(task, sanitizeTaskPayload(payload))
      const newStoryId = task.story_id
      if (oldStoryId !== newStoryId) {
        const oldList = s.relations.taskIdsByStory[oldStoryId]
        if (oldList) {
          s.relations.taskIdsByStory[oldStoryId] = oldList.filter((tid) => tid !== id)
        }
        const targetList = s.relations.taskIdsByStory[newStoryId] ?? []
        if (!targetList.includes(id)) targetList.push(id)
        s.relations.taskIdsByStory[newStoryId] = sortTaskIds(s.entities.tasksById, targetList)
      } else if (s.relations.taskIdsByStory[oldStoryId]) {
        s.relations.taskIdsByStory[oldStoryId] = sortTaskIds(
          s.entities.tasksById,
          s.relations.taskIdsByStory[oldStoryId],
        )
      }
    })
    return
  }

  // I
  if (get().entities.tasksById[id]) return
  set((s) => {
    const task = coerceTaskPayload(id, payload)
    s.entities.tasksById[id] = task
    const list = s.relations.taskIdsByStory[task.story_id] ?? []
    list.push(id)
    s.relations.taskIdsByStory[task.story_id] = sortTaskIds(s.entities.tasksById, list)
  })
}

function sortPbiIds(byId: Record<string, BacklogPbi>, ids: string[]): string[] {
  return [...new Set(ids)]
    .filter((id) => byId[id] !== undefined)
    .sort((a, b) => comparePbi(byId[a], byId[b]))
}

function sortStoryIds(byId: Record<string, BacklogStory>, ids: string[]): string[] {
  return [...new Set(ids)]
    .filter((id) => byId[id] !== undefined)
    .sort((a, b) => compareStory(byId[a], byId[b]))
}

function sortTaskIds(
  byId: Record<string, BacklogTask | TaskDetail>,
  ids: string[],
): string[] {
  return [...new Set(ids)]
    .filter((id) => byId[id] !== undefined)
    .sort((a, b) => compareTask(byId[a], byId[b]))
}

function sanitizePbiPayload(p: Record<string, unknown>): Partial<BacklogPbi> {
  const { entity: _e, op: _o, ...rest } = p
  void _e
  void _o
  if (typeof rest.status === 'string') {
    rest.status = normalizePbiStatusForStore(rest.status)
  }
  return rest as Partial<BacklogPbi>
}

function sanitizeStoryPayload(p: Record<string, unknown>): Partial<BacklogStory> {
  const {
    entity: _e,
    op: _o,
    story_status,
    story_sort_order,
    story_title,
    story_code,
    ...rest
  } = p
  void _e
  void _o
  if (rest.status === undefined && typeof story_status === 'string') {
    rest.status = story_status
  }
  if (rest.sort_order === undefined && typeof story_sort_order === 'number') {
    rest.sort_order = story_sort_order
  }
  if (rest.title === undefined && typeof story_title === 'string') {
    rest.title = story_title
  }
  if (rest.code === undefined && (typeof story_code === 'string' || story_code === null)) {
    rest.code = story_code
  }
  if (typeof rest.status === 'string') {
    rest.status = normalizeStoryStatusForStore(rest.status)
  }
  return rest as Partial<BacklogStory>
}

function sanitizeTaskPayload(p: Record<string, unknown>): Partial<BacklogTask> {
  const {
    entity: _e,
    op: _o,
    task_status,
    task_sort_order,
    task_title,
    ...rest
  } = p
  void _e
  void _o
  if (rest.status === undefined && typeof task_status === 'string') {
    rest.status = task_status
  }
  if (rest.sort_order === undefined && typeof task_sort_order === 'number') {
    rest.sort_order = task_sort_order
  }
  if (rest.title === undefined && typeof task_title === 'string') {
    rest.title = task_title
  }
  if (typeof rest.status === 'string') {
    rest.status = normalizeTaskStatusForStore(rest.status)
  }
  return rest as Partial<BacklogTask>
}

function coercePbiPayload(id: string, p: Record<string, unknown>): BacklogPbi {
  return {
    id,
    code: (p.code as string | null) ?? null,
    title: String(p.title ?? ''),
    priority: Number(p.priority ?? 4),
    sort_order: Number(p.sort_order ?? 0),
    description: (p.description as string | null | undefined) ?? null,
    created_at:
      p.created_at instanceof Date
        ? p.created_at
        : new Date(String(p.created_at ?? Date.now())),
    status: normalizePbiStatusForStore(String(p.status ?? 'ready')),
  }
}

function coerceStoryPayload(id: string, p: Record<string, unknown>): BacklogStory {
  const status = p.status ?? p.story_status ?? 'OPEN'
  const sortOrder = p.sort_order ?? p.story_sort_order ?? 0
  const title = p.title ?? p.story_title ?? ''
  const code = p.code ?? p.story_code ?? null
  return {
    id,
    code: (code as string | null) ?? null,
    title: String(title),
    description: (p.description as string | null | undefined) ?? null,
    acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null,
    priority: Number(p.priority ?? 4),
    sort_order: Number(sortOrder),
    status: normalizeStoryStatusForStore(String(status)),
    pbi_id: String(p.pbi_id ?? ''),
    sprint_id: (p.sprint_id as string | null | undefined) ?? null,
    created_at:
      p.created_at instanceof Date
        ? p.created_at
        : new Date(String(p.created_at ?? Date.now())),
  }
}

function coerceTaskPayload(id: string, p: Record<string, unknown>): BacklogTask {
  const status = p.status ?? p.task_status ?? 'TO_DO'
  const sortOrder = p.sort_order ?? p.task_sort_order ?? 0
  const title = p.title ?? p.task_title ?? ''
  return {
    id,
    code: (p.code as string | null | undefined) ?? null,
    title: String(title),
    description: (p.description as string | null | undefined) ?? null,
    priority: Number(p.priority ?? 4),
    sort_order: Number(sortOrder),
    status: normalizeTaskStatusForStore(String(status)),
    story_id: String(p.story_id ?? ''),
    created_at:
      p.created_at instanceof Date
        ? p.created_at
        : new Date(String(p.created_at ?? Date.now())),
  }
}
