import {findById, removeById} from '@crumbs/core/collections'
import {
  CrumbSchema,
  type CreateCrumbInput,
  type Crumb,
  type Trail,
  type TrailPostAuthor,
  type UpdateCrumbInput,
} from '@crumbs/core/schemas'
import {create} from 'zustand'

import {createSlotStore, slotHooks} from '@/lib/async-slot'
import {removeCrumbImage, uploadCrumbImage} from '@/lib/crumb-image'
import {type Draft} from '@/lib/draft'
import {crumbDropped, crumbSynced} from '@/lib/entity-sync'
import {tolerateStatus, unwrap} from '@/lib/errors'
import {
  emptyKeyedDetail,
  keyedDetailHooks,
  loadKeyed,
  persistKeyedDetail,
  setDetail,
  type KeyedDetail,
} from '@/lib/keyed-resource'
import {
  applyOptimisticUpdate,
  optimisticCrumb,
} from '@/lib/optimistic-crumb'
import {
  imageOutboxHandler,
  stageImage,
  type ImageAction,
} from '@/lib/outbox-image'
import {
  createPagedStore,
  pagedHooks,
  pageParams,
} from '@/lib/paged-resource'
import {rpc} from '@/lib/rpc'
import {fromSchema} from '@/lib/snapshot-cache'
import {
  keyedDetailSnapshot,
  listSnapshot,
  pagedSnapshot,
} from '@/lib/snapshots'
import {keyedStaleGuard} from '@/lib/stale-guard'
import {registerReset} from '@/lib/store-reset'
import {uuid} from '@/lib/uuid'
import {requireUser} from '@/stores/auth'
import {refreshFeed, useFeedCrumb} from '@/stores/feed'
import {invalidateMapPlaces} from '@/stores/map-places'
import {
  afterFlush,
  defineMutation,
  discardQueuedCreate,
  shouldReconcile,
} from '@/stores/outbox'

export type {Crumb}

export type AddCrumbInput = {
  draft: Draft
  tags: string[]
}

// the viewer's own recent crumbs: cursor-paged, feeding the map camera
// hint, the map preview and search recents (the social feed lives in
// @/stores/feed)
const useRecentCrumbsStore = createPagedStore<Crumb>(
  async (cursor) => {
    const {crumbs, nextCursor} = await unwrap(
      rpc.crumbs.$get({query: pageParams(cursor)}),
    )
    return {items: crumbs, nextCursor}
  },
  pagedSnapshot('crumbs-recent', 1, fromSchema(CrumbSchema)),
)

// upcoming crumbs, queried server-side so it sees every crumb rather than
// deriving from a recent page
const usePlannedStore = createSlotStore<Crumb[]>(
  [],
  async () => {
    const {crumbs} = await unwrap(rpc.crumbs.planned.$get())
    return crumbs
  },
  listSnapshot('crumbs-planned', 1, fromSchema(CrumbSchema)),
)

// at most one target: a picked geocoder result, a resolved entity from
// extraction, or free-form hints that become a provisional place
const draftTarget = (draft: Draft) => {
  if (draft.placeRef) return {placeRef: draft.placeRef}
  if (draft.placeId) return {placeId: draft.placeId}
  if (draft.address || draft.latitude != null)
    return {
      placeDraft: {
        name: draft.name,
        address: draft.address ?? undefined,
        latitude: draft.latitude,
        longitude: draft.longitude,
      },
    }
  return {}
}

const draftToPayload = (draft: Draft, tags: string[]) => ({
  name: draft.name,
  note: draft.note ?? undefined,
  source: draft.source,
  sourceId: draft.sourceId ?? undefined,
  tags,
  ...draftTarget(draft),
})

// per-id detail cache + the create/edit/delete mutations. every mutation
// applies optimistically and enqueues: the ui updates and the call returns at
// once, the outbox replays the write when the network allows and reconciles
// the optimistic copy to the server truth
type CrumbsState = KeyedDetail<Crumb> & {
  loadDetail: (id: string) => Promise<void>
  add: (input: AddCrumbInput) => Promise<Crumb>
  // undefined when the crumb isn't held locally (evicted from the detail
  // cache and past the recents page): the edit still queues and syncs, there
  // is just no local copy to update
  update: (
    id: string,
    input: UpdateCrumbInput,
  ) => Promise<Crumb | undefined>
  uploadImage: (id: string, uri: string) => Promise<void>
  removeImage: (id: string) => Promise<void>
  remove: (id: string) => Promise<void>
}

// outbox kinds, scoped by crumb id so a crumb's create → edit → image → delete
// always replay in that order (different crumbs replay in parallel). the image
// shares the crumb's scope, so its upload lands after the crumb exists
const CRUMB_CREATE = 'crumb.create'
const CRUMB_UPDATE = 'crumb.update'
const CRUMB_DELETE = 'crumb.delete'
const CRUMB_IMAGE = 'crumb.image'

// any create/edit/delete can change what's upcoming and which pins belong on
// the map; both are server-derived, so resync rather than guess locally.
// coalesced so a bulk drain resyncs each once when it settles, not per entry
const afterCrumbMutation = () => {
  afterFlush(
    'planned',
    () => void usePlannedStore.getState().refresh(),
  )
  afterFlush('map', invalidateMapPlaces)
}

const detailGuard = keyedStaleGuard()
const detailSnap = keyedDetailSnapshot(
  'crumb-detail',
  1,
  fromSchema(CrumbSchema),
)

// viewer-relative context the detail response carries alongside the crumb:
// its author and the trails it belongs to that the viewer can read.
// ephemeral (refetched with the detail), so no snapshot
type CrumbContext = {
  author: TrailPostAuthor | null
  trails: Trail[]
}

const useCrumbContextStore = create<{
  byId: Record<string, CrumbContext>
}>(() => ({byId: {}}))

registerReset(() => useCrumbContextStore.setState({byId: {}}))

export const useCrumbContext = (
  id: string,
): CrumbContext | undefined => useCrumbContextStore((s) => s.byId[id])

const useCrumbsStore = create<CrumbsState>((set) => ({
  ...emptyKeyedDetail,
  byId: detailSnap.seed(),
  loadDetail: (id) =>
    loadKeyed(set, detailGuard, id, async () => {
      const {crumb, author, trails} = await unwrap(
        rpc.crumbs[':id'].$get({param: {id}}),
      )
      useCrumbContextStore.setState((s) => ({
        byId: {...s.byId, [id]: {author, trails}},
      }))
      return crumb
    }),
  add: async ({draft, tags}) => {
    const user = requireUser()
    // client-mint the id so the optimistic copy and the queued create share
    // it: a replayed create upserts on this pk, so a lost ack never doubles
    const id = uuid()
    const crumb = optimisticCrumb(draft, tags, {
      id,
      authorId: user.id,
      now: new Date().toISOString(),
    })
    useRecentCrumbsStore.getState().prepend(crumb)
    set((s) => ({byId: setDetail(s.byId, id, crumb)}))
    crumbSynced.publish(crumb)
    queueCrumbCreate({
      scope: id,
      id,
      label: crumb.name,
      payload: {id, ...draftToPayload(draft, tags)},
    })
    return crumb
  },
  update: async (id, input) => {
    const current = getCrumbById(id)
    const optimistic = current
      ? applyOptimisticUpdate(
          current,
          input,
          new Date().toISOString(),
        )
      : undefined
    if (optimistic) applyCrumb(optimistic)
    queueCrumbUpdate({
      scope: id,
      label: optimistic?.name,
      payload: {id, input},
    })
    return optimistic
  },
  uploadImage: async (id, uri) => {
    // stage a durable copy, show it at once (expo-image renders file://), and
    // queue the upload in the crumb's scope so it replays after the crumb's
    // own create — no more waiting on whenScopeSettled
    const imageId = uuid()
    const staged = await stageImage(imageId, uri)
    const name = applyCrumbImage(id, staged)
    queueCrumbImage({
      scope: id,
      id: imageId,
      label: name,
      payload: {crumbId: id, action: 'set', imageId},
    })
  },
  removeImage: async (id) => {
    const name = applyCrumbImage(id, null)
    queueCrumbImage({
      scope: id,
      label: name,
      payload: {crumbId: id, action: 'remove'},
    })
  },
  remove: async (id) => {
    const name = getCrumbById(id)?.name
    dropCrumb(id)
    if (discardQueuedCreate(CRUMB_CREATE, id)) return
    queueCrumbDelete({scope: id, label: name, payload: {id}})
  },
}))

persistKeyedDetail(useCrumbsStore, detailSnap, detailGuard)

// commit a crumb (an optimistic patch or the canonical server copy) to the
// recent list + detail cache, then broadcast it to every other holder (the
// feed, trail details, place lists). insertIfMissing re-adds a create whose
// optimistic copy a list refresh may have dropped before the create synced —
// edits never insert, so an edited crumb absent from recents isn't hoisted in
const applyCrumb = (crumb: Crumb, insertIfMissing = false) => {
  // supersede any in-flight loadDetail so a stale server copy can never
  // land over this fresher one
  detailGuard.claim(crumb.id)
  const recent = useRecentCrumbsStore.getState()
  if (findById(recent.items, crumb.id))
    recent.replace(crumb.id, crumb)
  else if (insertIfMissing) recent.prepend(crumb)
  useCrumbsStore.setState((s) => ({
    byId: setDetail(s.byId, crumb.id, crumb),
  }))
  crumbSynced.publish(crumb)
}

// drop a crumb from every holder — optimistic delete, and the rollback for a
// create that can never sync
const dropCrumb = (id: string) => {
  // fence an in-flight loadDetail: without the claim its late resolve would
  // re-create (and re-persist) the deleted slot
  detailGuard.claim(id)
  useRecentCrumbsStore.getState().removeItem(id)
  useCrumbsStore.setState((s) => {
    const {[id]: _removed, ...byId} = s.byId
    return {byId}
  })
  crumbDropped.publish(id)
}

// keep the planned tab in step with mutations that haven't hit the server
// yet (offline edits): patch a held copy in place, drop one that clearly
// left (done / date cleared). membership is server-derived, so insertions
// and re-ordering belong to the post-sync refresh
crumbSynced.subscribe((crumb) =>
  usePlannedStore.setState((s) => {
    if (!findById(s.data, crumb.id)) return s
    const stillPlanned =
      crumb.status !== 'done' && crumb.plannedFor != null
    return {
      data: stillPlanned
        ? s.data.map((c) => (c.id === crumb.id ? crumb : c))
        : removeById(s.data, crumb.id),
    }
  }),
)

crumbDropped.subscribe((id) =>
  usePlannedStore.setState((s) => ({data: removeById(s.data, id)})),
)

// optimistically set or clear a crumb's image everywhere and return its name
// (for the queued entry's label); undefined if the crumb isn't held locally
const applyCrumbImage = (
  id: string,
  imageUrl: string | null,
): string | undefined => {
  const current =
    useCrumbsStore.getState().byId[id]?.data ??
    findById(useRecentCrumbsStore.getState().items, id)
  if (!current) return undefined
  applyCrumb({
    ...current,
    imageUrl,
    updatedAt: new Date().toISOString(),
  })
  return current.name
}

// the outbox replays each queued mutation and reconciles to the server truth.
// a create upserts on the client id (idempotent under a replayed lost ack); an
// edit or delete that 404s means the crumb is already gone — that's success,
// so the queued write is dropped, never resurrected. reconciles are gated on
// shouldReconcile: while the scope still holds queued followers (an edit, an
// image) this response is older than their optimistic effects, so only the
// chain's last ack repaints
const queueCrumbCreate = defineMutation<
  CreateCrumbInput & {id: string}
>(CRUMB_CREATE, {
  run: async (payload) => {
    const {crumb} = await unwrap(rpc.crumbs.$post({json: payload}))
    if (shouldReconcile(payload.id)) applyCrumb(crumb, true)
    afterFlush('feed', () => void refreshFeed())
    afterCrumbMutation()
  },
  rollback: ({id}) => dropCrumb(id),
  createsScope: true,
})

// rollback-by-refetch: a discarded edit/delete leaves optimistic state the
// server never saw, so restore the server copy everywhere. a fetch failure
// (offline, 404) leaves things as they are — the next refetch heals
const restoreCrumb = (id: string, insertIfMissing = false): void => {
  void unwrap(rpc.crumbs[':id'].$get({param: {id}}))
    .then(({crumb}) => {
      applyCrumb(crumb, insertIfMissing)
      void usePlannedStore.getState().refresh()
      invalidateMapPlaces()
    })
    .catch(() => {})
}

const queueCrumbUpdate = defineMutation<{
  id: string
  input: UpdateCrumbInput
}>(CRUMB_UPDATE, {
  run: async ({id, input}) => {
    await tolerateStatus(404, async () => {
      const {crumb} = await unwrap(
        rpc.crumbs[':id'].$patch({param: {id}, json: input}),
      )
      if (shouldReconcile(id)) applyCrumb(crumb)
      afterCrumbMutation()
    })
  },
  rollback: ({id}) => restoreCrumb(id),
})

const queueCrumbDelete = defineMutation<{id: string}>(CRUMB_DELETE, {
  run: async ({id}) => {
    await tolerateStatus(404, () =>
      unwrap(rpc.crumbs[':id'].$delete({param: {id}})),
    )
    afterCrumbMutation()
  },
  // the crumb is still alive server-side; bring it back into the lists
  rollback: ({id}) => restoreCrumb(id, true),
})

// upload the staged image (or clear it) once the crumb exists server-side;
// the shared handler reconciles the crumb to the server's content-addressed
// url and frees the local copy. a discarded remove restores the server copy
const queueCrumbImage = defineMutation<
  {crumbId: string} & ImageAction
>(
  CRUMB_IMAGE,
  imageOutboxHandler({
    entityId: (payload) => payload.crumbId,
    upload: uploadCrumbImage,
    remove: removeCrumbImage,
    shouldReconcile,
    reconcile: (crumb) => applyCrumb(crumb),
    clearOptimistic: (id, stagedUri) => {
      const current = getCrumbById(id)
      if (current?.imageUrl === stagedUri)
        applyCrumb({...current, imageUrl: null})
    },
    restore: (id) => restoreCrumb(id),
  }),
)

export const {
  useItems: useRecentCrumbs,
  useLoading: useRecentCrumbsLoading,
  useError: useRecentCrumbsError,
  useLoad: useLoadRecentCrumbs,
} = pagedHooks(useRecentCrumbsStore)

export const {
  useData: usePlanned,
  useLoading: usePlannedLoading,
  useError: usePlannedError,
  useRefresh: useRefreshPlanned,
} = slotHooks(usePlannedStore)

const {
  useData: useCrumbDetail,
  useLoading: useCrumbDetailLoading,
  useError: useCrumbDetailError,
} = keyedDetailHooks(useCrumbsStore)

// prefer the freshest copy: detail cache, then own recents, then the feed
export const useCrumb = (id: string) => {
  const detail = useCrumbDetail(id)
  const recent = useRecentCrumbsStore((s) => findById(s.items, id))
  const feed = useFeedCrumb(id)
  return detail ?? recent ?? feed
}

// non-hook lookup of an owned crumb (detail cache, then recents) — lets another
// store optimistically render a crumb it only holds by id, e.g. filing one into
// a trail offline. own crumbs live in these two; the feed holds friends'
export const getCrumbById = (id: string): Crumb | undefined =>
  useCrumbsStore.getState().byId[id]?.data ??
  findById(useRecentCrumbsStore.getState().items, id)
export {useCrumbDetailError, useCrumbDetailLoading}

// the detail-screen face of the crumb cache (for @/hooks/use-detail): the
// freshest held copy plus the detail load's flags
export const crumbDetail = {
  useData: useCrumb,
  useLoading: useCrumbDetailLoading,
  useError: useCrumbDetailError,
  useLoad: () => useCrumbsStore((s) => s.loadDetail),
}
export const useLoadCrumbDetail = () =>
  useCrumbsStore((s) => s.loadDetail)
export const useAddCrumb = () => useCrumbsStore((s) => s.add)
export const useUpdateCrumb = () => useCrumbsStore((s) => s.update)
export const useUploadCrumbImage = () =>
  useCrumbsStore((s) => s.uploadImage)
export const useRemoveCrumbImage = () =>
  useCrumbsStore((s) => s.removeImage)
export const useRemoveCrumb = () => useCrumbsStore((s) => s.remove)
