import {PublicUserSchema, type PublicUser} from '@crumbs/core/schemas'
import {create} from 'zustand'

import {deleteAvatar, uploadAvatar} from '@/lib/avatar'
import {
  isApiStatus,
  isNetworkError,
  toApiError,
  unwrap,
} from '@/lib/errors'
import {type Author} from '@/lib/optimistic-trail'
import {
  deleteStagedImage,
  hasStagedImage,
  stagedImageUri,
  stageImage,
  type ImageAction,
} from '@/lib/outbox-image'
import {rpc, setUnauthorizedHandler} from '@/lib/rpc'
import {
  clearSessionToken,
  getSessionToken,
  setSessionToken,
} from '@/lib/session-token'
import {fromSchema} from '@/lib/snapshot-cache'
import {snapshot} from '@/lib/snapshots'
import {resetAllStores} from '@/lib/store-reset'
import {uuid} from '@/lib/uuid'
import {defineMutation, supersedeMutation} from '@/stores/outbox'

// undefined = pre-hydrate, null = signed-out
type AuthState = {
  user: PublicUser | null | undefined
  // set when the session ended without an explicit sign out (expired or
  // revoked); sign-in surfaces it as a notice
  sessionExpired: boolean
  setSession: (token: string) => Promise<boolean>
  signOut: () => Promise<void>
  updateHandle: (handle: string) => Promise<void>
  updateAvatar: (uri: string) => Promise<void>
  removeAvatar: () => Promise<void>
}

// null is an auth verdict (the session is gone); anything else that isn't a
// user — a 5xx, a network failure — throws, so callers can tell "signed out"
// from "no answer" and never end a valid session over a transient error
const fetchMe = async (): Promise<PublicUser | null> => {
  const res = await rpc.auth.me.$get()
  if (res.status === 401) return null
  if (!res.ok) throw await toApiError(res)
  const {user} = await res.json()
  return user
}

// the signed-in user survives restarts so an offline cold start can trust
// the stored session instead of stranding the user at sign-in
const userSnapshot = snapshot(
  'auth-user',
  1,
  fromSchema(PublicUserSchema),
)

// one avatar per account; the queue is per-account (wiped on switch), so a
// single scope serialises rapid changes and last-write-wins by replay order
const AVATAR = 'avatar'
// likewise one handle per account. re-setting your own handle is a no-op
// server-side (same row, no unique violation), so a replay is idempotent
const HANDLE = 'handle'

// named so the supersede read below can share the enqueuer's payload type
type HandlePayload = {handle: string; prevHandle: string}

// avatar and handle replay independently: a dead-lettered handle conflict
// must never wedge a queued avatar upload behind it (and vice versa)
const avatarScope = (userId: string | undefined): string =>
  `avatar:${userId ?? ''}`
const handleScope = (userId: string | undefined): string =>
  `handle:${userId ?? ''}`

export const useAuth = create<AuthState>((set) => ({
  user: undefined,
  sessionExpired: false,
  setSession: async (token) => {
    await setSessionToken(token)
    // a failed sign-in is a false, never a throw: the callback screen
    // routes on the result and must not strand its spinner
    const user = await fetchMe().catch(() => null)
    if (!user) {
      await clearSessionToken()
      set({user: null})
      return false
    }
    // a fresh session may belong to a different account; start from
    // empty caches either way
    resetAllStores()
    set({user, sessionExpired: false})
    return true
  },
  signOut: async () => {
    try {
      await rpc.auth.logout.$post()
    } catch {}
    await clearSessionToken()
    resetAllStores()
    set({user: null, sessionExpired: false})
  },
  updateHandle: async (handle) => {
    // uniqueness is server-checked, so attempt it live — the settings screen
    // surfaces handle_taken from the thrown error. only when the request never
    // reached the server (offline) do we apply optimistically + queue it; a
    // handle grabbed by then dead-letters and reverts on replay
    const me = requireUser()
    const scope = handleScope(me.id)
    try {
      const {user} = await unwrap(
        rpc.auth.handle.$patch({json: {handle}}),
      )
      set({user})
      // an older queued change is superseded — left queued it would replay
      // after this one and overwrite the newer handle
      supersedeMutation(HANDLE, scope)
    } catch (err) {
      if (!isNetworkError(err)) throw err
      // replace any older queued change (one HANDLE entry per scope), keeping
      // its prevHandle so a revert lands on the true pre-change value
      const superseded = supersedeMutation(HANDLE, scope)
      const prevHandle =
        (superseded?.payload as HandlePayload | undefined)
          ?.prevHandle ?? me.handle
      set((s) => (s.user ? {user: {...s.user, handle}} : s))
      queueHandle({
        scope,
        label: `@${handle}`,
        payload: {handle, prevHandle},
      })
    }
  },
  updateAvatar: async (uri) => {
    // stage a durable copy, show it at once, and queue the upload — the
    // change syncs when the network allows, offline included. the prior url
    // rides along so a discarded change can revert instead of leaving the
    // optimistic uri dangling
    const me = requireUser()
    const imageId = uuid()
    const staged = await stageImage(imageId, uri)
    const prevAvatarUrl = me.avatarUrl ?? null
    set((s) => (s.user ? {user: {...s.user, avatarUrl: staged}} : s))
    queueAvatar({
      scope: avatarScope(me.id),
      id: imageId,
      payload: {action: 'set', imageId, prevAvatarUrl},
    })
  },
  removeAvatar: async () => {
    const me = requireUser()
    const imageId = uuid()
    const prevAvatarUrl = me.avatarUrl ?? null
    set((s) => (s.user ? {user: {...s.user, avatarUrl: null}} : s))
    queueAvatar({
      scope: avatarScope(me.id),
      id: imageId,
      payload: {action: 'remove', prevAvatarUrl},
    })
  },
}))

// the signed-in user, for mutations only they can perform — an author-less
// optimistic copy would fail its schema on the next cold start, discarding
// the whole cached list
export const requireUser = (): PublicUser => {
  const user = useAuth.getState().user
  if (!user) throw new Error('signed out')
  return user
}

// the author shape optimistic copies embed
export const authorOf = ({
  id,
  handle,
  avatarUrl,
}: Author): Author => ({
  id,
  handle,
  avatarUrl,
})

// the outbox replays the avatar change and reconciles the user to the server
// copy (its content-addressed url), then frees the staged file
const queueAvatar = defineMutation<
  {prevAvatarUrl: string | null} & ImageAction
>(AVATAR, {
  run: async (payload) => {
    // a missing staged copy (purged, or a malformed payload) can never
    // upload — moot, drop the write instead of throwing on every replay
    if (payload.action === 'set' && !hasStagedImage(payload.imageId))
      return
    const user =
      payload.action === 'remove'
        ? await deleteAvatar()
        : await uploadAvatar(stagedImageUri(payload.imageId))
    // a concurrent scope's 401 may have ended the session mid-drain;
    // repainting the user here would resurrect a signed-out account
    useAuth.setState((s) => (s.user ? {user} : s))
    if (payload.action === 'set') deleteStagedImage(payload.imageId)
  },
  // a discarded change reverts to the prior url (like revertHandle), so the
  // ui never keeps an avatar the server refused — or a dangling staged uri
  rollback: (payload) => {
    useAuth.setState((s) =>
      // a stale persisted payload may predate prevAvatarUrl — skip the revert
      s.user && payload.prevAvatarUrl !== undefined
        ? {user: {...s.user, avatarUrl: payload.prevAvatarUrl}}
        : s,
    )
    if (payload.action === 'set') deleteStagedImage(payload.imageId)
  },
})

// re-set the handle (idempotent), reconciling the user to the server copy. a
// 409 means it was taken while offline — revert so the ui never shows a handle
// the server rejected, then let it dead-letter so the failure is visible
const revertHandle = (prevHandle?: string): void =>
  useAuth.setState((s) =>
    s.user && prevHandle !== undefined
      ? {user: {...s.user, handle: prevHandle}}
      : s,
  )

const queueHandle = defineMutation<HandlePayload>(HANDLE, {
  run: async ({handle, prevHandle}) => {
    try {
      const {user} = await unwrap(
        rpc.auth.handle.$patch({json: {handle}}),
      )
      // guarded like the avatar ack: never resurrect a signed-out user
      useAuth.setState((s) => (s.user ? {user} : s))
    } catch (err) {
      if (isApiStatus(err, 409)) revertHandle(prevHandle)
      throw err
    }
  },
  // discard reverts a handle change that never landed
  rollback: ({prevHandle}) => revertHandle(prevHandle),
})

// true once initial hydrate() resolves
export const useAuthReady = (): boolean =>
  useAuth((s) => s.user !== undefined)

// a 401 on any authed request means the session is gone; end it once,
// centrally. skipped when already signed out (fresh sign-in failures)
setUnauthorizedHandler(() => {
  if (useAuth.getState().user === null) return
  void clearSessionToken()
  resetAllStores()
  useAuth.setState({user: null, sessionExpired: true})
})

useAuth.subscribe((state, prev) => {
  if (state.user === prev.user) return
  if (state.user) userSnapshot.write(state.user)
  else userSnapshot.clear()
})

const hydrate = async () => {
  const token = await getSessionToken()
  if (!token) {
    useAuth.setState({user: null})
    return
  }
  try {
    const user = await fetchMe()
    if (!user) await clearSessionToken()
    useAuth.setState({user: user ?? null})
  } catch {
    // no verdict (offline, 5xx): trust the stored session and cached
    // profile; a real 401 later still ends the session centrally
    useAuth.setState({user: userSnapshot.read() ?? null})
  }
}

void hydrate()

export const requestMagicLink = async (
  email: string,
): Promise<{ok: boolean; devVerifyUrl?: string}> => {
  return unwrap(rpc.auth.request.$post({json: {email}}))
}

// dev/e2e only — skips the email + safari + deep-link round trip. the api
// route 404s in production, so this can never mint a session there
export const devLogin = async (email: string): Promise<void> => {
  const {token} = await unwrap(rpc.dev.login.$post({json: {email}}))
  const ok = await useAuth.getState().setSession(token)
  if (!ok) throw new Error('dev login failed')
}
