import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'expo-router'
import { useShareIntentContext } from 'expo-share-intent'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { postNewStatus } from 'src/lib/api'
import { ApiError } from 'src/lib/api-context'
import type { Status, Visibility } from 'src/lib/api-types'
import { formatHostForLimitMsg } from 'src/lib/branding'
import { useInstanceCapabilities } from 'src/lib/capabilities'
import {
  type Classification,
  captureMedia,
  classifyForUpload,
  clearDraft,
  getMediaLimits,
  getMediaLimitsForMastodon,
  loadDraft,
  type MediaAsset,
  type MediaLimits,
  needsConsent,
  PermissionDeniedError,
  type PermissionKind,
  type ProcessOutcome,
  pickMedia,
  processMediaBatch,
  type RawAsset,
  saveDraft,
  toDraftMedia,
  uploadMediaBatch,
} from 'src/lib/media'
import { accountQueries } from 'src/lib/queries/accounts'
import { cameraQueries } from 'src/lib/queries/camera'
import { queryKeys } from 'src/lib/queries/keys'
import { appendReplyToCachedThread, postQueries } from 'src/lib/queries/posts'
import { emit } from 'src/lib/tapestry/emit'
import { coerceVisibility } from 'src/lib/visibility'
import { useAuth } from 'src/state/AuthProvider'

type ComposerMode = 'idle' | 'resizing' | 'compressing' | 'uploading' | 'posting'

// Single-shot summary emitted at the end of every `ingestRaw` run that
// produced at least one outcome (success or non-aborted failure). The
// composer screen reads this from the hook return and surfaces it as a
// toast on the busy→idle transition (see PostComposer's busyLabel
// useEffect). `version` is bumped per emission so two ingests with
// identical messages still trigger a fresh toast; the screen tracks the
// last consumed version in a ref to avoid re-showing on unrelated
// re-renders. Aborted-only batches return `null` (no user feedback owed).
export type IngestResult = {
  title: string
  message?: string
  preset?: 'success' | 'error'
  version: number
}

export type UseComposerArgs = {
  quotedStatusId: string | null
  /** When set, the post is sent with `in_reply_to_id` and the surface
   * branches into reply mode: skips the draft pipeline, skips the empty-
   * media slab, swaps the placeholder, and the success path lands the
   * server-stamped reply into every cached thread containing the parent
   * before `router.back()`-ing instead of routing to home. */
  inReplyToId: string | null
  /** The reply target Status, optional, just for the in-modal banner +
   * the visibility-cascade init. Caller passes whatever it has cached
   * from `postQueries.detail(inReplyToId)`. */
  inReplyTo: Status | null
  /** Caller's user id, to invalidate the profile feed after publishing. */
  userId: string | null
  /** True for private accounts — forces scope to `private`. */
  lockedAccount: boolean
  /** Server enforces "must set alt text" UX. */
  requireSelfAltText: boolean
  /** Whether to apply square crop in the picker. */
  mediaEdit: boolean
  /** Callback to show non-blocking errors. */
  onError: (title: string, message: string) => void
  /** Callback to prompt the user. Return `true` to resume the draft. */
  onResumeDraftPrompt?: (savedAt: number) => Promise<boolean>
  /** Permission screen handler — called when the camera is denied. */
  onPermissionDenied: (kind: PermissionKind) => void
}

export function useComposer(args: UseComposerArgs) {
  const router = useRouter()
  const queryClient = useQueryClient()
  const { hasShareIntent, shareIntent } = useShareIntentContext()
  // Mastodon Pass §4.1: the composer's behaviour (limits source, media-required
  // gating, default scope, allowed-when-empty) branches on the active account's
  // type. Reading through `useAuth` makes the hook re-render when the active
  // account flips so the composer picks up the new account's limits without a
  // remount; in practice the screen is unmounted between flips today, but the
  // dependency is correct either way.
  const { user: account } = useAuth()
  const isMastodon = account?.type === 'mastodon'

  const [caption, setCaption] = useState('')
  const [scope, setScope] = useState<Visibility>('public')
  const [sensitive, setSensitive] = useState(false)
  // Compose Redesign Phase 3: optional content-warning text. Sent as
  // `spoiler_text` on submit when the post is also flagged sensitive (the
  // sensitive flag is the carrier; spoilerText is the human-facing string).
  // Empty string is the only "no CW" marker — server-side both Pixelfed and
  // Mastodon treat empty `spoiler_text` as absent.
  const [spoilerText, setSpoilerText] = useState('')
  const [media, setMedia] = useState<MediaAsset[]>([])
  const [mode, setMode] = useState<ComposerMode>('idle')
  const [compressionProgress, setCompressionProgress] = useState(0)
  const [hydrated, setHydrated] = useState(false)

  // Compose Phase 5 — pre-process consent gate. When `pickMedia()` returns
  // assets that would be silently resized/compressed (or are too far over
  // the limit to recover), we open `<ResizeConsentSheet>` and suspend the
  // ingest pipeline on a promise resolved by the sheet's callbacks. The
  // promise lives in `consentResolverRef` rather than state so re-renders
  // don't drop the resolver mid-flight.
  const [pendingConsent, setPendingConsent] = useState<{
    items: Classification[]
    hostLabel: string
  } | null>(null)
  const consentResolverRef = useRef<((accepted: boolean) => void) | null>(null)

  // Consolidated ingest outcome. See IngestResult docs above.
  const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
  const ingestVersionRef = useRef(0)

  const abortRef = useRef<AbortController | null>(null)
  const isMountedRef = useRef(true)

  // Pixelfed-only: instance + compose-settings probes. `getComposeSettings`
  // hits `api/v1.1/compose/settings`, which is a Pixelfed-extended endpoint
  // and asserts a Pixelfed `ApiContext` — running it under a Mastodon-active
  // account would throw. `enabled: !isMastodon` keeps both queries silent on
  // Mastodon; the Mastodon limits come from `useInstanceCapabilities` below.
  const {
    data: instance,
    isPending: instancePendingRaw,
    error: instanceError,
  } = useQuery({
    ...cameraQueries.instance(),
    enabled: !isMastodon,
  })
  const { data: composeSettings, isPending: composePendingRaw } = useQuery({
    ...cameraQueries.composeSettings(),
    enabled: !isMastodon && !!instance,
  })
  const instancePending = isMastodon ? false : instancePendingRaw
  const composePending = isMastodon ? false : composePendingRaw

  // Mastodon limits source (Phase 2 capability cache). `useInstanceCapabilities`
  // returns the spec defaults synchronously when the cache is cold (plan
  // §9.1.11) so this never blocks render. Only consulted when `isMastodon`.
  const capabilities = useInstanceCapabilities()

  // Default scope plumbing per plan §9.3.18: prefer the active account's
  // `source.privacy` from `verify_credentials` (works on both servers, more
  // accurate than a server-default), and fall back to Pixelfed's
  // compose-settings default or the universal `'public'`. Coerced through
  // `coerceVisibility` so a fork-emitted value (`'local'`, etc.) doesn't
  // propagate into composer state.
  const { data: self } = useQuery(accountQueries.self())
  const accountSourcePrivacy: Visibility | null = self?.source?.privacy
    ? coerceVisibility(self.source.privacy)
    : null

  // Compose Redesign Phase 3 §3.6 — server-side coercion signals. Both
  // surface as banners under the toolbar so the user knows the post will
  // not land exactly as composed:
  //  - `alwaysCw`: account-level "Always require content warning" preference
  //    (Pixelfed `profile.cw=true` / Mastodon "Always mark media as
  //    sensitive"). `verify_credentials.source.sensitive` is the cross-server
  //    carrier.
  //  - `unlistedDefault`: account-level default privacy is `unlisted`.
  //    Pixelfed silently coerces `public → unlisted` for these accounts; the
  //    banner only fires when the user has explicitly picked `public` so we
  //    don't double-up with the existing "won't appear on public timelines"
  //    line that fires for explicit `unlisted`.
  const alwaysCw = !!self?.source?.sensitive
  const unlistedDefault = accountSourcePrivacy === 'unlisted'

  const limits: MediaLimits = useMemo(() => {
    if (isMastodon) return getMediaLimitsForMastodon(capabilities)
    return getMediaLimits(instance ?? null, composeSettings ?? null)
  }, [isMastodon, capabilities, instance, composeSettings])

  // Resolved default scope used on first hydration. Coerce on every source so
  // a fork-only value never lands in `scope` state.
  const defaultScope: Visibility = useMemo(() => {
    return (
      accountSourcePrivacy ??
      (composeSettings?.default_scope
        ? coerceVisibility(composeSettings.default_scope)
        : null) ??
      coerceVisibility(limits.defaultScope)
    )
  }, [accountSourcePrivacy, composeSettings, limits.defaultScope])

  const { data: quotedStatus } = useQuery({
    ...postQueries.detail(args.quotedStatusId ?? ''),
    enabled: !!args.quotedStatusId,
  })

  // Initialise scope once enough server context has loaded.
  // Pixelfed: gated on `composeSettings` to preserve the prior init timing
  // (the existing UI showed a spinner until compose-settings landed). Mastodon:
  // the capability cache is always synchronously available (defaults or live);
  // gate on `account` instead so the effect runs on the first render after
  // sign-in completes.
  //
  // Reply-aware cascade: lockedAccount → private; else if parent is non-public
  // (`unlisted` / `private` / `direct`) → parent's visibility wins (don't
  // accidentally widen a private convo); else (parent public, or no reply
  // target) → user's server-side default via `defaultScope`. Mastodon-web's
  // `getInitialState` follows the same shape.
  const parentVisibility = args.inReplyTo?.visibility
  useEffect(() => {
    if (!isMastodon && !composeSettings) return
    if (isMastodon && !account) return
    if (args.lockedAccount) {
      setScope('private')
      return
    }
    if (hydrated) return
    if (parentVisibility && parentVisibility !== 'public') {
      setScope(coerceVisibility(parentVisibility))
      return
    }
    setScope(defaultScope)
  }, [
    isMastodon,
    composeSettings,
    account,
    args.lockedAccount,
    hydrated,
    defaultScope,
    parentVisibility,
  ])

  // Compose Redesign Phase 3 §3.6 — when the account's "Always require CW"
  // preference is set, force `sensitive=true` on every fresh compose so the
  // CW input renders by default and the user's toolbar reads what the server
  // will enforce. Driven by `verify_credentials.source.sensitive`. Only
  // applied before draft hydration so a resumed draft with `sensitive=false`
  // is not silently flipped (the user can still choose to leave sensitive
  // off; the server will coerce it back at submit time and the banner makes
  // that visible).
  useEffect(() => {
    if (hydrated) return
    if (alwaysCw) setSensitive(true)
  }, [hydrated, alwaysCw])

  // Resume a saved draft if one exists. Runs once, after hydrated = false.
  // Replies skip the draft pipeline entirely — the modal-close-without-post
  // case is rare for replies, persisting one would collide with the user's
  // pending top-level draft, and "which target was this drafted for"
  // is a question we'd rather not answer in v1. Per-reply drafts are
  // captured in plan P8b for future work.
  useEffect(() => {
    let cancelled = false
    async function run() {
      if (hydrated) return
      if (args.inReplyToId) {
        setHydrated(true)
        return
      }
      const draft = loadDraft()
      if (!draft) {
        setHydrated(true)
        return
      }
      const resume = args.onResumeDraftPrompt
        ? await args.onResumeDraftPrompt(draft.savedAt)
        : false
      if (cancelled) return
      if (resume) {
        setCaption(draft.caption)
        setScope(coerceVisibility(draft.scope))
        setSensitive(draft.sensitive)
        // Pre-Phase-3 drafts lack `spoilerText`; default to '' so a resumed
        // legacy draft still hydrates cleanly.
        setSpoilerText(draft.spoilerText ?? '')
        setMedia(
          draft.media.map((m) => ({
            uri: m.uri,
            kind: m.kind,
            mimeType: m.mimeType,
            altText: m.altText,
            width: m.width,
            height: m.height,
            durationMs: m.durationMs,
          }))
        )
      } else {
        clearDraft()
      }
      setHydrated(true)
    }
    run()
    return () => {
      cancelled = true
    }
  }, [hydrated, args.onResumeDraftPrompt, args.inReplyToId])

  // Debounced draft save. Replies opt out of save the same way they opt out
  // of load — see the resume effect for the rationale.
  useEffect(() => {
    if (!hydrated) return
    if (mode === 'posting') return
    if (args.inReplyToId) return
    const t = setTimeout(() => {
      saveDraft({
        caption,
        scope,
        sensitive,
        spoilerText,
        media: toDraftMedia(media),
        quotedStatusId: args.quotedStatusId,
      })
    }, 600)
    return () => clearTimeout(t)
  }, [
    caption,
    scope,
    sensitive,
    spoilerText,
    media,
    mode,
    hydrated,
    args.quotedStatusId,
    args.inReplyToId,
  ])

  // Share intent — ingest incoming images once.
  useEffect(() => {
    if (!hasShareIntent || !shareIntent.files?.length) return
    const file = shareIntent.files[0]
    ingestShared(file.path, file.mimeType ?? 'image/jpeg')
    // ingestShared is stable enough — it only reads `limits`, which is refed.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [hasShareIntent])

  // Unmount safety.
  useEffect(() => {
    return () => {
      isMountedRef.current = false
      abortRef.current?.abort(new Error('unmounted'))
    }
  }, [])

  const makeController = useCallback(() => {
    abortRef.current?.abort(new Error('superseded'))
    const ctrl = new AbortController()
    abortRef.current = ctrl
    return ctrl
  }, [])

  const hostLabel = useMemo(
    () => formatHostForLimitMsg(account?.instance ?? null),
    [account?.instance]
  )

  const ingestRaw = useCallback(
    async (raws: RawAsset[]) => {
      if (!raws.length) return

      // Compose Phase 5 — classify every picked asset against the live
      // per-instance MediaLimits before processing. Items that are within
      // limits (or only need silent housekeeping like HEIC→JPEG / MOV→MP4
      // remux) flow straight through; anything user-visible (resize a 12 MB
      // photo, compress a 60 MB video, hard-reject a 220 MB clip) goes
      // through the consent sheet so the user knows what's about to happen
      // and can opt out. The sheet enumerates the whole batch — one decision
      // for the whole pick, not one prompt per asset.
      let classified: Classification[] = raws.map((raw) =>
        classifyForUpload(raw, limits, {
          supportedImageMimeTypes: capabilities.supportedImageMimeTypes,
        })
      )

      if (needsConsent(classified)) {
        // Drop blocking items if the user accepts; otherwise, abandon the
        // batch entirely. The sheet's "Remove from post" button calls
        // setPendingConsent → we drop the item and let the user re-confirm.
        const accepted = await new Promise<boolean>((resolve) => {
          consentResolverRef.current = resolve
          setPendingConsent({ items: classified, hostLabel })
        })
        consentResolverRef.current = null
        setPendingConsent(null)
        if (!accepted) return
        // After the user accepts, drop any remaining blocking items as a
        // belt-and-suspenders guard (the sheet's commit button is gated on
        // !hasBlockingItem so this is normally a no-op, but the dropped-item
        // re-confirm path means the array we land on here is already
        // filtered).
        classified = classified.filter(
          (c) =>
            c.tag !== 'too_big_to_resize' &&
            c.tag !== 'too_big_to_compress' &&
            c.tag !== 'unsupported_mime'
        )
      }

      const acceptedRaws = classified.map((c) => c.raw)
      if (!acceptedRaws.length) return

      const ctrl = makeController()
      setMode('resizing')
      setCompressionProgress(0)

      const opts = {
        // Thread the live per-instance limits all the way down. Both
        // resize.ts and compress.ts honour these as the authoritative cap;
        // the hard-coded `DEFAULT_MAX_IMAGE_BYTES = 5 MB` in resize.ts only
        // fires if a caller forgets to pass `maxImageBytes`, which would
        // throw away quality on instances advertising larger limits.
        maxVideoBytes: limits.maxVideoBytes,
        maxImageBytes: limits.maxImageBytes,
        maxImageWidth: 4096,
        imageQuality: 0.85,
        // Drives the JPEG-vs-WebP encode decision in `resizeImage`. WebP wins
        // when the server publishes it (always on Mastodon; opt-in on
        // Pixelfed); JPEG falls out as the conservative default when the
        // capability cache is cold or the field is missing.
        supportedImageMimeTypes: capabilities.supportedImageMimeTypes,
        forceMp4: true,
        signal: ctrl.signal,
        onVideoProgress: (fraction: number) => {
          if (!isMountedRef.current) return
          setMode('compressing')
          setCompressionProgress(fraction)
        },
      }

      const outcomes: ProcessOutcome[] = await processMediaBatch(acceptedRaws, opts)
      if (!isMountedRef.current || ctrl.signal.aborted) {
        setMode('idle')
        return
      }

      const accepted: MediaAsset[] = []
      for (const outcome of outcomes) {
        if (outcome.ok) accepted.push(outcome.asset)
      }
      if (accepted.length) {
        setMedia((prev) => [...prev, ...accepted])
      }
      // Single consolidated outcome for the whole batch — replaces the
      // previous per-item `args.onError` loop. The screen renders this as
      // one toast on the busy→idle transition (success / partial /
      // error). `hostLabel` is threaded in so the failure copy can name
      // the instance enforcing the limit (process.ts is decoupled from
      // instance state, so the rewrite happens here).
      const summary = summarizeIngestOutcomes(acceptedRaws, outcomes, hostLabel)
      if (summary) {
        setIngestResult({ ...summary, version: ++ingestVersionRef.current })
      }
      setMode('idle')
      setCompressionProgress(0)
    },
    [
      limits.maxImageBytes,
      limits.maxVideoBytes,
      capabilities.supportedImageMimeTypes,
      hostLabel,
      makeController,
      args,
    ]
  )

  // Consent-sheet callbacks. Surfaced on the hook return so the composer
  // screen can wire them straight into <ResizeConsentSheet>.
  const handleConsentConfirm = useCallback(() => {
    consentResolverRef.current?.(true)
  }, [])
  const handleConsentCancel = useCallback(() => {
    consentResolverRef.current?.(false)
  }, [])
  const handleConsentRemoveItem = useCallback((uri: string) => {
    setPendingConsent((prev) => {
      if (!prev) return prev
      const nextItems = prev.items.filter((c) => c.raw.uri !== uri)
      // If the user has just dropped the last item, treat the sheet as
      // cancelled — there's nothing to upload.
      if (!nextItems.length) {
        consentResolverRef.current?.(false)
        return null
      }
      // If no items still need consent (only within/transcode-only remain),
      // auto-accept so the user doesn't have to tap a second confirm.
      if (!needsConsent(nextItems)) {
        consentResolverRef.current?.(true)
        return null
      }
      return { ...prev, items: nextItems }
    })
  }, [])

  const ingestShared = useCallback(
    async (path: string, mimeType: string) => {
      await ingestRaw([
        {
          uri: path,
          kind: 'photo',
          mimeType,
        },
      ])
    },
    [ingestRaw]
  )

  const handlePickFromLibrary = useCallback(async () => {
    try {
      const remaining = Math.max(1, limits.maxAttachments - media.length)
      const raws = await pickMedia({
        kinds: ['photo', 'video'],
        multiple: !args.mediaEdit,
        limit: remaining,
        squareCrop: args.mediaEdit,
        ordered: true,
      })
      await ingestRaw(raws)
    } catch (e) {
      if (e instanceof PermissionDeniedError) args.onPermissionDenied(e.kind)
    }
  }, [
    args.mediaEdit,
    limits.maxAttachments,
    media.length,
    ingestRaw,
    args.onPermissionDenied,
  ])

  const handleCaptureFromCamera = useCallback(async () => {
    try {
      const raws = await captureMedia(['photo', 'video'])
      await ingestRaw(raws)
    } catch (e) {
      if (e instanceof PermissionDeniedError) args.onPermissionDenied(e.kind)
    }
  }, [ingestRaw, args.onPermissionDenied])

  const setAltText = useCallback((index: number, altText: string) => {
    setMedia((prev) =>
      prev.map((m, i) => (i === index ? { ...m, altText: altText || null } : m))
    )
  }, [])

  const removeMedia = useCallback((uri: string) => {
    setMedia((prev) => prev.filter((m) => m.uri !== uri))
  }, [])

  const reset = useCallback(() => {
    setCaption('')
    setSensitive(false)
    setSpoilerText('')
    setMedia([])
    setScope(defaultScope)
    setCompressionProgress(0)
    setMode('idle')
    clearDraft()
  }, [defaultScope])

  // Mastodon Pass §4.1 / §4.2: Mastodon allows text-only posts; Pixelfed has
  // always required at least one media attachment. The `mediaRequired` flag on
  // `MediaLimits` is the single source of truth for this branch — when false,
  // a non-empty caption alone is enough to enable the post button.
  //
  // Replies carve out of Pixelfed's `mediaRequired` gate: replies are
  // conversational, the server accepts text-only replies fine, and pinning
  // replies behind a "you must add an image" gate would break parity with
  // every other client. Media on replies is still allowed via the toolbar —
  // we just don't *demand* it.
  const hasContent = limits.mediaRequired
    ? media.length > 0 || !!args.quotedStatusId || !!args.inReplyToId
    : caption.trim().length > 0 || media.length > 0 || !!args.quotedStatusId

  const canPost =
    hasContent && mode === 'idle' && hydrated && !instancePending && !composePending

  const handlePost = useCallback(async () => {
    if (!canPost) return
    if (args.requireSelfAltText) {
      const missing = media.some((m) => !m.altText || !m.altText.length)
      if (missing) {
        args.onError(
          'Missing Alt Text',
          'You have enabled the requirement for alt text, and we found missing alt text. Please add alt text for each media to proceed.'
        )
        return
      }
    }

    // Multi-account guard: if the user switched accounts after picking,
    // the limits we resized against may be looser than what the now-active
    // server enforces. Re-classify the already-processed media; any item
    // that's still over the new limit blocks the post with a clear message.
    // Within-budget items (the common case after an account switch to a
    // similar instance) pass through silently. Re-resizing in-place would
    // require keeping the source file alive past upload, which the cache
    // cleanup hook actively undoes — surfacing the conflict is the safer
    // call.
    const reclassified = media.map((m) =>
      classifyForUpload(m as RawAsset, limits, {
        supportedImageMimeTypes: capabilities.supportedImageMimeTypes,
      })
    )
    const stillTooBig = reclassified.find(
      (c) =>
        c.tag === 'too_big_to_resize' ||
        c.tag === 'too_big_to_compress' ||
        c.tag === 'will_resize' ||
        c.tag === 'will_compress'
    )
    if (stillTooBig) {
      args.onError(
        `${hostLabel} caps media`,
        `One of your attachments is over ${hostLabel}'s limit. Remove it and pick again.`
      )
      return
    }
    const ctrl = makeController()
    setMode('uploading')
    const upload = await uploadMediaBatch(media, ctrl.signal)
    if (!isMountedRef.current) return
    if (!upload.ok) {
      setMode('idle')
      args.onError('Error uploading media', upload.message)
      return
    }
    setMode('posting')
    try {
      // Mastodon Pass §4.2: outbound params are documented Mastodon shape on
      // both servers — `status`, `media_ids`, `visibility`, `sensitive`, plus
      // optional `language`, `quoted_status_id`, `quote_approval_policy`.
      // Pixelfed-only fields the composer might add later (`place`,
      // `collection_ids`, `bouquet`) MUST stay gated to Pixelfed contexts —
      // the place/collection picker entry points the plan §4.2 references
      // do not exist yet, but the gate goes here when they do.
      const params: Record<string, unknown> = {
        status: caption,
        media_ids: upload.ids,
        visibility: scope,
        sensitive: sensitive,
      }
      // Compose Redesign Phase 3: ship CW on both server types per §0.3 of
      // the validation pass — `spoiler_text` is already typed and
      // round-tripped on the Mastodon edit path (`mastodon.ts:286`,
      // `usePostEdit.ts:28`). The flag is gated to `sensitive === true` to
      // match the UI's coupling: the CW input only appears when sensitive
      // is on, so a value reaching here without sensitive would be stale.
      // Empty string is treated as absent on both servers; we drop the key
      // entirely to keep the request body tidy.
      if (sensitive && spoilerText.trim().length > 0) {
        params.spoiler_text = spoilerText.trim()
      }
      // §9.3.20: send a default `language` on every post. Both servers accept
      // it; non-EN users get correct attribution out of the box. Best-effort —
      // when the runtime can't resolve a locale we omit the field rather than
      // hardcode `'en'`, which would be a regression for the existing default-
      // omitted behaviour.
      const language = resolveDefaultLanguage()
      if (language) params.language = language
      if (args.quotedStatusId) {
        params.quoted_status_id = args.quotedStatusId
        params.quote_approval_policy = limits.quotePolicy
      }
      // Replies on Pixelfed and Mastodon are just statuses with
      // `in_reply_to_id` set; the underlying endpoint is the same
      // `POST /api/v1/statuses` `postNewStatus` already targets, so the API
      // layer needs no work.
      if (args.inReplyToId) {
        params.in_reply_to_id = args.inReplyToId
      }
      const reply = (await postNewStatus(params)) as Status
      // Reply branch: emit `'C'` (the comment letter — preserves the
      // tapestry semantic the prior sticky-composer post-mutation used),
      // patch every cached thread containing the parent so the originating
      // screen sees the new row without a refetch, then `router.back()` to
      // return. Top-level posts emit `'P'` and route to home as before.
      if (args.inReplyToId) {
        appendReplyToCachedThread(args.inReplyToId, reply)
        emit('C')
        if (!isMountedRef.current) return
        reset()
        router.back()
        return
      }
      emit('P')
      if (!isMountedRef.current) return
      reset()
      router.replace('/(auth)/(tabs)/home')
      if (args.userId) {
        // Invalidate via the byUser prefix so the new post lands in every
        // variant cache (Pixelfed grid + Pixelfed microblog + Mastodon all)
        // — Phase 2.3 split the single profile-statuses cache into three.
        queryClient.invalidateQueries({
          queryKey: queryKeys.posts.byUser(args.userId),
        })
      }
    } catch (err) {
      if (!isMountedRef.current) return
      setMode('idle')
      const explained = explainPostError(err)
      args.onError(explained.title, explained.message)
    }
  }, [
    args,
    caption,
    canPost,
    capabilities.supportedImageMimeTypes,
    hostLabel,
    limits,
    makeController,
    media,
    queryClient,
    reset,
    router,
    scope,
    sensitive,
    spoilerText,
  ])

  return {
    caption,
    setCaption,
    scope,
    setScope,
    sensitive,
    setSensitive,
    spoilerText,
    setSpoilerText,
    alwaysCw,
    unlistedDefault,
    media,
    mode,
    compressionProgress,
    canPost,
    limits,
    hydrated,
    instancePending,
    composePending,
    instanceError,
    quotedStatus: quotedStatus as Status | undefined,
    handlePickFromLibrary,
    handleCaptureFromCamera,
    handlePost,
    setAltText,
    removeMedia,
    reset,
    // Compose Phase 5 — consent sheet plumbing. The sheet itself lives in
    // <PostComposer> so it shares the screen's modal/z-index stack with the
    // alt-text and scope sheets; the hook owns the state because it owns
    // the suspended ingest promise.
    pendingConsent,
    hostLabel,
    handleConsentConfirm,
    handleConsentCancel,
    handleConsentRemoveItem,
    // Consolidated outcome of the most recent ingestRaw run, surfaced
    // to the screen as a single toast on the busy→idle transition.
    // Bumped `version` per emission so the consumer can distinguish
    // "new outcome to show" from "no change".
    ingestResult,
  }
}

// Compose Redesign Phase 1 §4.3: map the catch-all "Error posting" toast
// onto status-specific user copy. The Pixelfed and Mastodon error bodies
// land in `ApiError.message` (the request layer parses `error_code` /
// `error` fields and stringifies them); substring matches cover the
// stable Pixelfed `abort()` strings (`size limit`, `idempotency`) without
// over-fitting to a particular phrasing. Anything not recognised falls
// through to the original "Couldn't post" + raw message so we never lose
// signal — the goal is better copy on the common cases, not exhaustive
// classification.
function explainPostError(err: unknown): { title: string; message: string } {
  if (err instanceof ApiError) {
    if (err.status === 429) {
      return {
        title: 'Posting limit reached',
        message: "You've hit your instance's daily limit. Try again later.",
      }
    }
    if (err.status === 403 && /size limit/i.test(err.message)) {
      return {
        title: 'Storage full',
        message: 'Delete some posts to free space, then try again.',
      }
    }
    if (err.status === 400 && /idempotency/i.test(err.message)) {
      return {
        title: 'Already posted',
        message: 'Pull down on the home feed — it should be there.',
      }
    }
    if (err.status === 422) {
      return {
        title: "Couldn't post",
        message: err.message || 'Validation failed.',
      }
    }
    return { title: "Couldn't post", message: err.message }
  }
  return {
    title: 'Error posting',
    message: (err as Error)?.message ?? 'Unknown error',
  }
}

// Compose Phase 5 — collapse a batch of `ProcessOutcome`s into one
// user-facing summary. Three flavours:
//   - all-success → green "Added 3 photos" / "Added 2 photos and 1 video"
//   - partial    → neutral "Added X" with a "(N items failed)" subline
//   - all-failed → red "Couldn't add media" with the first reason as detail
// Aborted outcomes are silenced (the user cancelled — they don't need
// confirmation). Returns `null` when nothing meaningful happened (e.g.,
// the whole batch was aborted) so the screen falls back to its default
// busy-toast hide.
//
// `hostLabel` is threaded through so `video_too_large` copy can name the
// instance enforcing the limit; process.ts is intentionally decoupled
// from instance state, so the rewrite happens here at the call site that
// has the active host.
function summarizeIngestOutcomes(
  raws: RawAsset[],
  outcomes: ProcessOutcome[],
  hostLabel: string
): { title: string; message?: string; preset?: 'success' | 'error' } | null {
  let photosAdded = 0
  let videosAdded = 0
  const failures: { kind: 'photo' | 'video'; message: string }[] = []

  outcomes.forEach((o, i) => {
    if (o.ok) {
      if (o.asset.kind === 'photo') photosAdded++
      else videosAdded++
    } else if (o.reason !== 'aborted') {
      const message =
        o.reason === 'video_too_large'
          ? o.message.replace('this instance', hostLabel)
          : o.message
      failures.push({ kind: raws[i]?.kind ?? 'photo', message })
    }
  })

  const totalAdded = photosAdded + videosAdded
  if (totalAdded === 0 && failures.length === 0) return null

  if (failures.length === 0) {
    return { title: formatAddedLine(photosAdded, videosAdded), preset: 'success' }
  }
  if (totalAdded === 0) {
    const title =
      failures.length === 1
        ? "Couldn't add media"
        : `Couldn't add ${failures.length} items`
    return { title, message: failures[0].message, preset: 'error' }
  }
  const message =
    failures.length === 1
      ? `1 item failed: ${failures[0].message}`
      : `${failures.length} items failed.`
  return { title: formatAddedLine(photosAdded, videosAdded), message }
}

function formatAddedLine(photos: number, videos: number): string {
  const photoStr = photos > 0 ? `${photos} ${photos === 1 ? 'photo' : 'photos'}` : ''
  const videoStr = videos > 0 ? `${videos} ${videos === 1 ? 'video' : 'videos'}` : ''
  if (photoStr && videoStr) return `Added ${photoStr} and ${videoStr}`
  return `Added ${photoStr || videoStr}`
}

// Mastodon Pass §9.3.20: resolve the device's preferred language to a
// two-letter ISO 639-1 tag for the outbound `language` param. Hermes ships
// with `Intl.DateTimeFormat().resolvedOptions().locale` (e.g. `'en-US'`),
// which is the most reliable cross-platform locale primitive without adding
// `expo-localization`. We split off the region suffix because Mastodon
// validates against the language code, not the full BCP 47 tag. Returns
// null when the runtime can't resolve a locale at all so the caller omits
// the field entirely (preserving today's "server picks a default" behaviour
// rather than asserting `'en'`).
function resolveDefaultLanguage(): string | null {
  try {
    const locale = new Intl.DateTimeFormat().resolvedOptions().locale
    if (!locale || typeof locale !== 'string') return null
    const lang = locale.split('-')[0].trim().toLowerCase()
    return lang.length >= 2 && lang.length <= 3 ? lang : null
  } catch {
    return null
  }
}
