import { Feather } from '@expo/vector-icons'
import { useRouter } from 'expo-router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, Keyboard } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { PermissionDeniedScreen } from 'src/components/common/PermissionDeniedScreen'
import { PressableOpacity } from 'src/components/common/PressableOpacity'
import { useScreenMenu } from 'src/components/menu'
import type { MenuOverflowItemIntent } from 'src/components/menu/intents'
import { useComposer } from 'src/hooks/useComposer'
import { useConfirmDialog } from 'src/hooks/useConfirmDialog'
import type { Status } from 'src/lib/api-types'
import { ICON } from 'src/lib/icons'
import type { PermissionKind } from 'src/lib/media'
import { countStatusCharacters } from 'src/lib/text/count-characters'
import { useAuth } from 'src/state/AuthProvider'
import { type InflightHandle, inflightStore } from 'src/state/InflightProvider'
import { appStorage, uiKeys } from 'src/state/storage'
import {
  Button,
  ScrollView,
  Text,
  TextArea,
  useTheme,
  useToastController,
  View,
  XStack,
  YStack,
} from 'tamagui'
import { AltTextSheet } from './AltTextSheet'
import { ComposerAccountRow } from './ComposerAccountRow'
import { ComposerBanner } from './ComposerBanner'
import { ComposerCWInput } from './ComposerCWInput'
import { ComposerScopePicker, SCOPE_ICON, SCOPE_LABEL } from './ComposerScopePicker'
import { MediaRail } from './MediaRail'
import { QuotedStatusBanner } from './QuotedStatusBanner'
import { ReplyIndicator } from './ReplyIndicator'
import { ResizeConsentSheet } from './ResizeConsentSheet'

type Props = {
  quotedStatusId: string | null
  /** When set, the composer is a reply to this status id. Drives
   * `params.in_reply_to_id`, the visibility cascade, the placeholder copy,
   * the empty-media slab carve-out, and the draft-overflow gating. */
  inReplyToId: string | null
  /** Optional cached reply target Status — surfaced inside the modal as
   * the `<ReplyIndicator>` so the user always sees what they're replying
   * to without leaving the composer. */
  inReplyTo: Status | null
  userId: string | null
  username: string | null
  displayName: string | null
  avatarUrl: string | null
  lockedAccount: boolean
  onCancelQuote: () => void
}

export function PostComposer({
  quotedStatusId,
  inReplyToId,
  inReplyTo,
  userId,
  username,
  displayName,
  avatarUrl,
  lockedAccount,
  onCancelQuote,
}: Props) {
  const theme = useTheme()
  const router = useRouter()
  const toast = useToastController()
  const { confirm, dialog: confirmDialog } = useConfirmDialog()
  // Phase 2 — read the active account's instance for the unambiguous
  // `@user@instance.tld` line. The registry value is stable for the
  // composer's lifetime: switching accounts only happens from
  // Settings → Accounts and the account-switch overlay unmounts the tab
  // tree before this screen comes back.
  const { user: account } = useAuth()

  const [activeAltIndex, setActiveAltIndex] = useState(-1)
  const [altSheetOpen, setAltSheetOpen] = useState(false)
  const [deniedKind, setDeniedKind] = useState<PermissionKind | null>(null)
  // Compose Redesign Phase 3: scope-picker sheet is opened from the toolbar
  // trigger (replaces the cycle button). Local state — the picker is a
  // dismissable surface and doesn't need to participate in the `useScreenMenu`
  // flow.
  const [scopePickerOpen, setScopePickerOpen] = useState(false)

  // Compose Redesign Phase 1: device-level prefs read once on mount, same
  // pattern as `requireSelfAltText`. The toggles' UI lives at
  // `Settings → Compose` now; mid-compose changes don't propagate (matches
  // pre-redesign behaviour) because the picker only consults `mediaEdit`
  // when invoked, and the user's intent is to set-and-forget.
  const requireSelfAltText = useMemo(
    () => appStorage.getBoolean(uiKeys.requireSelfAltText) == true,
    []
  )
  const mediaEdit = useMemo(() => appStorage.getBoolean(uiKeys.mediaEdit) == true, [])

  const onError = useCallback(
    (title: string, message: string) => {
      toast.show(title, { message, preset: 'error' })
    },
    [toast]
  )

  const onResumeDraftPrompt = useCallback(
    (savedAt: number): Promise<boolean> => {
      const ageMin = Math.max(1, Math.round((Date.now() - savedAt) / 60_000))
      const label =
        ageMin < 60
          ? `${ageMin} min ago`
          : ageMin < 60 * 24
            ? `${Math.round(ageMin / 60)} hr ago`
            : `${Math.round(ageMin / (60 * 24))} d ago`
      return confirm({
        title: 'Resume draft?',
        message: `You have an unsent post from ${label}. Continue editing it?`,
        confirmLabel: 'Resume',
        cancelLabel: 'Discard',
      })
    },
    [confirm]
  )

  const composer = useComposer({
    quotedStatusId,
    inReplyToId,
    inReplyTo,
    userId,
    lockedAccount,
    requireSelfAltText,
    mediaEdit,
    onError,
    onResumeDraftPrompt,
    onPermissionDenied: setDeniedKind,
  })

  const openAltText = useCallback((index: number) => {
    setActiveAltIndex(index)
    setAltSheetOpen(true)
  }, [])

  const busy = composer.mode !== 'idle'

  // Mastodon Pass §9.3.17: character count is server-shape-aware. Mastodon
  // reserves a fixed cost per URL (typically 23) regardless of length;
  // Pixelfed counts raw characters. The composer hook surfaces the
  // `charactersReservedPerUrl` source-of-truth on `limits` and we project
  // it into both the visible counter and the post-time validity gate.
  const captionCount = useMemo(
    () =>
      countStatusCharacters(composer.caption, composer.limits.charactersReservedPerUrl),
    [composer.caption, composer.limits.charactersReservedPerUrl]
  )
  const overLimit = captionCount > composer.limits.maxCharacters
  const canPost = composer.canPost && !overLimit

  // Compose Redesign Phase 5 — banner inputs derived from server state +
  // local composer state. Each is a one-line `useMemo` so the parent doesn't
  // re-render the banner on every keystroke when none of these signals
  // changed (the banner already early-returns on `lines.length === 0`, but
  // recomputing the inputs object on every render still allocates).
  const hasMissingAlt = useMemo(
    () => composer.media.some((m) => !m.altText || !m.altText.length),
    [composer.media]
  )
  // The composer's quote-author flow only fires when there's a quoted
  // status; on Mastodon you can't quote a `private` post at all (the server
  // rejects the request before this banner would render), so the banner is
  // primarily a Pixelfed-relevant signal — Pixelfed's quote flow accepts
  // quoting a private post but the resulting quote is only visible to the
  // original author's followers, which is what the banner copy explains.
  const quoteIsPrivate = composer.quotedStatus?.visibility === 'private'

  // Route composer async state onto the global InflightBar + a persistent
  // toast. A single handle spans the entire pipeline (resize/compress →
  // upload → post) so the bar stays lit continuously; updateLabel reflects
  // whichever stage is live. setProgress feeds the compressor's percentage
  // into the bar's determinate fill.
  //
  // On the busy→idle transition we replace the persistent busy toast with
  // a transient ingest summary (Added N photos / failure / partial), if
  // the hook emitted one for this batch. Replacing — not hide+show —
  // matters because Tamagui's toast viewport renders one toast at a time;
  // calling toast.show after toast.hide leaves a flicker, and calling
  // hide after show kills the success message. The version-gated ref
  // tracks the last-consumed `ingestResult` so this useEffect doesn't
  // re-show the toast on unrelated re-renders.
  const inflightRef = useRef<InflightHandle | null>(null)
  const lastConsumedIngestVersionRef = useRef<number>(0)
  const busyLabel = busyLabelFor(composer.mode, composer.compressionProgress)
  const ingestResult = composer.ingestResult
  useEffect(() => {
    if (busyLabel) {
      if (inflightRef.current === null) {
        inflightRef.current = inflightStore.begin(busyLabel)
        toast.show(busyLabel, { duration: 0 })
      } else {
        inflightStore.updateLabel(inflightRef.current, busyLabel)
        toast.show(busyLabel, { duration: 0 })
      }
    } else if (inflightRef.current !== null) {
      inflightStore.end(inflightRef.current)
      inflightRef.current = null
      if (ingestResult && ingestResult.version !== lastConsumedIngestVersionRef.current) {
        lastConsumedIngestVersionRef.current = ingestResult.version
        toast.show(ingestResult.title, {
          message: ingestResult.message,
          preset: ingestResult.preset,
          duration: 2500,
        })
      } else {
        toast.hide()
      }
    }
  }, [busyLabel, ingestResult, toast])
  useEffect(() => {
    if (inflightRef.current === null) return
    if (composer.mode === 'compressing') {
      inflightStore.setProgress(inflightRef.current, composer.compressionProgress)
    } else {
      inflightStore.setProgress(inflightRef.current, 0)
    }
  }, [composer.mode, composer.compressionProgress])
  useEffect(() => {
    return () => {
      if (inflightRef.current !== null) {
        inflightStore.end(inflightRef.current)
        inflightRef.current = null
        toast.hide()
      }
    }
  }, [toast])

  // Compose Redesign Phase 4 §3.5 — "More" overflow. The plan-as-written
  // pictured a bespoke `<ComposerOverflowSheet>`; the existing `menu` intent
  // already renders an overflow Tamagui Menu via `RenderMenu` /
  // `MenuOverflowButton`, with built-in `confirm` plumbing through the shared
  // `ConfirmDialog`. Reusing that path keeps the composer consistent with
  // every other screen's overflow (post detail, profile, chats, article
  // detail) and removes the need for a new sheet primitive — strictly more
  // reuse than introducing a parallel sheet just for this screen.
  //
  // Items conditionally surface based on composer state. The plan's "Coming
  // soon" placeholders (Add to collection, Add location, Help with
  // composing, Default visibility…) are not shipped: collection/location are
  // future Pixelfed-only fields with no consumer today, and the docs URL the
  // help item would target doesn't exist. Per the project's "don't ship
  // half-features" guidance these stay un-shipped indefinitely.
  const disabledReason = useMemo(() => {
    if (canPost || busy) return null
    if (overLimit) return 'Your caption is over the character limit.'
    if (!composer.hydrated) return 'Loading your draft…'
    if (composer.instancePending || composer.composePending) {
      return 'Loading your instance settings…'
    }
    if (composer.limits.mediaRequired && composer.media.length === 0) {
      return 'Add a photo or video before posting.'
    }
    return 'Write something or add a photo before posting.'
  }, [
    canPost,
    busy,
    overLimit,
    composer.hydrated,
    composer.instancePending,
    composer.composePending,
    composer.limits.mediaRequired,
    composer.media.length,
  ])

  const overflowItems = useMemo<MenuOverflowItemIntent[]>(() => {
    const items: MenuOverflowItemIntent[] = []
    // CW input is gated on `sensitive`. Offering "Add content warning" when
    // the input is already visible would be redundant; offering it when the
    // server will coerce sensitive on regardless (`alwaysCw`) would be
    // misleading since the input *also* renders unconditionally.
    if (!composer.sensitive && !composer.alwaysCw) {
      items.push({
        id: 'add-cw',
        label: 'Add content warning',
        icon: 'eye-off',
        onPress: () => composer.setSensitive(true),
      })
    }
    if (composer.media.length > 0) {
      items.push({
        id: 'replace-media',
        label: 'Replace media',
        icon: 'refresh-cw',
        confirm: 'Replace your selected media with a new pick?',
        confirmTitle: 'Replace media',
        confirmLabel: 'Replace',
        onPress: () => {
          for (const m of composer.media) composer.removeMedia(m.uri)
          void composer.handlePickFromLibrary()
        },
      })
    }
    // Replies skip the draft pipeline (see `useComposer`'s resume / save
    // effects), so the "Save draft" / "Discard draft" overflow labels would
    // be misleading. The route-back via the header X is the only escape on
    // the reply path, matching the modal-compose mental model.
    if (!inReplyToId) {
      items.push({
        id: 'save-exit',
        label: 'Save draft and exit',
        icon: 'save',
        onPress: () => router.replace('/(auth)/(tabs)/home'),
      })
      items.push({
        id: 'discard',
        label: 'Discard draft',
        icon: 'trash-2',
        destructive: true,
        confirm: "Discard this draft? This can't be undone.",
        confirmTitle: 'Discard draft',
        confirmLabel: 'Discard',
        onPress: () => {
          composer.reset()
          router.replace('/(auth)/(tabs)/home')
        },
      })
    }
    if (disabledReason) {
      items.push({
        id: 'why-disabled',
        label: 'Why is the post button disabled?',
        icon: 'help-circle',
        onPress: () => {
          toast.show("Can't post yet", { message: disabledReason, duration: 5000 })
        },
      })
    }
    return items
  }, [
    composer.sensitive,
    composer.alwaysCw,
    composer.media,
    composer.setSensitive,
    composer.removeMedia,
    composer.handlePickFromLibrary,
    composer.reset,
    disabledReason,
    inReplyToId,
    router,
    toast,
  ])

  useScreenMenu({
    intents: [
      {
        kind: 'menu',
        accessibilityLabel: 'More post options',
        items: overflowItems,
      },
      {
        kind: 'commit',
        id: 'post',
        icon: 'send',
        label: canPost
          ? 'Post'
          : overLimit
            ? 'Caption is over the character limit'
            : 'Add media or caption before posting',
        onPress: composer.handlePost,
        state: busy ? 'busy' : canPost ? 'idle' : 'disabled',
      },
    ],
  })

  if (composer.instancePending || composer.composePending) {
    return <ActivityIndicator style={{ flex: 1 }} color={theme.color.val} />
  }
  if (composer.instanceError) {
    return <Text>Error: {composer.instanceError.message}</Text>
  }

  if (deniedKind) {
    return (
      <PermissionDeniedScreen
        kind={deniedKind}
        onRetry={() => setDeniedKind(null)}
        onCancel={() => setDeniedKind(null)}
      />
    )
  }

  return (
    <SafeAreaView
      edges={['top']}
      style={{ flex: 1, backgroundColor: theme.background.val }}
    >
      <View
        flex={1}
        bg="$background"
        pointerEvents={busy ? 'none' : 'auto'}
        opacity={busy ? 0.5 : 1}
      >
        <ScrollView onScroll={() => Keyboard.dismiss()}>
          <YStack px="$3" pt="$3" pb="$1">
            <ComposerAccountRow
              avatarUrl={avatarUrl}
              displayName={displayName}
              username={username}
              instance={account?.instance ?? null}
              captionCount={captionCount}
              maxCharacters={composer.limits.maxCharacters}
              overLimit={overLimit}
            />

            {quotedStatusId ? (
              <QuotedStatusBanner
                status={composer.quotedStatus}
                onDismiss={onCancelQuote}
              />
            ) : null}

            {inReplyToId ? <ReplyIndicator target={inReplyTo} /> : null}

            {composer.sensitive ? (
              <ComposerCWInput
                value={composer.spoilerText}
                onChangeText={composer.setSpoilerText}
                // Pixelfed has a hardcoded 140-char cap on `spoiler_text`
                // (`ApiV1Controller.php:3735`). Mastodon has no hard cap on
                // the field, so the counter is omitted there — hiding the
                // counter is the only visible difference from the user's POV
                // (the input itself ships on both server types per §0.3 of
                // the validation pass: `spoiler_text` is already typed and
                // round-tripped through `mastodon.ts:286` /
                // `usePostEdit.ts:28`).
                maxLength={
                  account?.type === 'mastodon' ? null : composer.limits.maxSpoilerLength
                }
              />
            ) : null}

            <YStack mt="$3">
              <TextArea
                testID="composer-caption-input"
                size="$4"
                fontSize="$7"
                borderWidth={1}
                bg={theme.backgroundTertiary.val}
                color={theme.color.val}
                borderColor={theme.borderColor.val}
                value={composer.caption}
                onChangeText={composer.setCaption}
                // Pixelfed counts raw characters and TextArea's `maxLength`
                // (a UTF-16 code-unit count) is a close-enough hard stop. On
                // Mastodon the cap is in code points and a URL counts as a
                // fixed reservation, so a TextArea-level cap would clip
                // mid-URL well before the server-validated count exceeds the
                // limit. Drop the prop whenever URL reservation is active and
                // gate posting on `captionCount` instead.
                maxLength={
                  composer.limits.charactersReservedPerUrl === null
                    ? composer.limits.maxCharacters
                    : undefined
                }
                numberOfLines={4}
                multiline
                placeholder={
                  inReplyToId
                    ? 'Reply…'
                    : quotedStatusId
                      ? 'Add a comment…'
                      : 'Share your moment...'
                }
                placeholderTextColor={theme.colorTertiary.val as any}
              />
            </YStack>

            <ComposerBanner
              inputs={{
                scope: composer.scope,
                sensitive: composer.sensitive,
                locked: lockedAccount,
                alwaysCw: composer.alwaysCw,
                unlistedDefault: composer.unlistedDefault,
                requiresAlt: requireSelfAltText,
                hasMissingAlt,
                quoteIsPrivate,
              }}
            />

            {/* Compose Redesign Phase 4 §3.4 — toolbar split. Left cluster
                groups "things you add to the post" (Camera, Photos); right
                cluster groups "post settings" (Sensitive, Scope picker). The
                "..." overflow lives in the screen header alongside Post,
                rendered by the menu intent above — no third inline icon
                here. The split is structural rather than visual chrome:
                each cluster is a plain XStack so the buttons don't carry
                an enclosing pill that would compete with the bordered
                ComposerBanner above. */}
            <XStack ml="$2" mt="$1" gap="$2" justifyContent="space-between">
              <XStack gap="$2">
                {!quotedStatusId &&
                composer.media.length < composer.limits.maxAttachments ? (
                  <>
                    <Button
                      testID="composer-add-media"
                      p="$0"
                      px="$2"
                      chromeless
                      transition="quick"
                      pressStyle={{ opacity: 0.7, scale: 0.95 }}
                      onPress={composer.handlePickFromLibrary}
                      accessibilityLabel="Add photo or video"
                    >
                      <Feather name="image" size={ICON.lg} color={theme.color.val} />
                    </Button>
                    <Button
                      p="$0"
                      px="$2"
                      chromeless
                      transition="quick"
                      pressStyle={{ opacity: 0.7, scale: 0.95 }}
                      onPress={composer.handleCaptureFromCamera}
                      accessibilityLabel="Capture from camera"
                    >
                      <Feather name="camera" size={ICON.lg} color={theme.color.val} />
                    </Button>
                  </>
                ) : null}
              </XStack>
              <XStack gap="$2" alignItems="center">
                <Button
                  p="$0"
                  px="$2"
                  chromeless
                  transition="quick"
                  pressStyle={{ opacity: 0.7, scale: 0.95 }}
                  onPress={() => composer.setSensitive(!composer.sensitive)}
                  // Phase 3 §3.6: when the account always requires a CW the
                  // server will coerce sensitive=true regardless of the
                  // toggle, so locking the toggle on matches what will be
                  // sent. The accompanying banner explains the coercion.
                  disabled={composer.alwaysCw}
                  accessibilityLabel="Mark media as sensitive"
                >
                  <Feather
                    name={composer.sensitive ? 'eye-off' : 'eye'}
                    size={ICON.lg}
                    color={theme.color.val}
                  />
                </Button>
                <Button
                  p="$0"
                  chromeless
                  transition="quick"
                  pressStyle={{ opacity: 0.7, scale: 0.98 }}
                  onPress={() => setScopePickerOpen(true)}
                  disabled={lockedAccount}
                  accessibilityLabel={`Visibility: ${SCOPE_LABEL[scopeKey(composer.scope)]}`}
                  accessibilityHint="Opens the visibility picker"
                >
                  <Text
                    color={theme.colorSecondary.val}
                    fontSize="$3"
                    allowFontScaling={false}
                  >
                    {SCOPE_LABEL[scopeKey(composer.scope)]}
                  </Text>
                  <Feather
                    name={SCOPE_ICON[scopeKey(composer.scope)]}
                    size={ICON.lg}
                    allowFontScaling={false}
                    color={theme.colorTertiary.val}
                  />
                  <Feather
                    name="chevron-down"
                    size={ICON.sm}
                    color={theme.colorTertiary.val}
                  />
                </Button>
              </XStack>
            </XStack>
          </YStack>

          <View ml="$3" mt="$3">
            {composer.media.length ? (
              <View testID="composer-media-rail">
                <MediaRail
                  media={composer.media}
                  onEditAltText={openAltText}
                  onRemove={composer.removeMedia}
                />
              </View>
            ) : quotedStatusId || inReplyToId ? null : (
              <View
                p="$3"
                bg="$gray2"
                borderWidth={1}
                borderColor={theme.borderColor.val}
                mr="$3"
                borderRadius={10}
                justifyContent="center"
                alignItems="center"
              >
                <XStack gap="$2" alignItems="center">
                  <Text
                    fontSize="$7"
                    lineHeight={30}
                    color={theme.color.val}
                    allowFontScaling={false}
                  >
                    Tap
                  </Text>
                  <PressableOpacity onPress={composer.handlePickFromLibrary} hitSlop={6}>
                    <Feather name="image" size={ICON.lg} color={theme.color.val} />
                  </PressableOpacity>
                  <Text
                    fontSize="$7"
                    lineHeight={30}
                    color={theme.color.val}
                    allowFontScaling={false}
                  >
                    {' '}
                    or{' '}
                  </Text>
                  <PressableOpacity
                    onPress={composer.handleCaptureFromCamera}
                    hitSlop={6}
                  >
                    <Feather name="camera" size={ICON.lg} color={theme.color.val} />
                  </PressableOpacity>
                  <Text
                    fontSize="$7"
                    lineHeight={30}
                    color={theme.color.val}
                    allowFontScaling={false}
                  >
                    {' '}
                    to add media
                  </Text>
                </XStack>
              </View>
            )}
          </View>
        </ScrollView>

        <AltTextSheet
          open={altSheetOpen}
          onOpenChange={setAltSheetOpen}
          media={activeAltIndex >= 0 ? (composer.media[activeAltIndex] ?? null) : null}
          maxLength={composer.limits.maxAltTextLength}
          onSave={(text) => {
            if (activeAltIndex >= 0) composer.setAltText(activeAltIndex, text)
            setAltSheetOpen(false)
          }}
        />
        <ComposerScopePicker
          open={scopePickerOpen}
          onOpenChange={setScopePickerOpen}
          value={composer.scope}
          onValueChange={composer.setScope}
          disabled={lockedAccount}
        />
        <ResizeConsentSheet
          open={composer.pendingConsent !== null}
          onOpenChange={(next) => {
            // The hook owns the resolver; closing the sheet via swipe or
            // overlay tap routes through onOpenChange(false) → onCancel.
            // Open transitions are driven by the hook setting state, not by
            // the sheet itself, so the only meaningful onOpenChange the
            // parent can produce here is `false`.
            if (!next) composer.handleConsentCancel()
          }}
          hostLabel={composer.hostLabel}
          items={composer.pendingConsent?.items ?? []}
          onConfirm={composer.handleConsentConfirm}
          onCancel={composer.handleConsentCancel}
          onRemoveItem={composer.handleConsentRemoveItem}
        />
        {confirmDialog}
      </View>
    </SafeAreaView>
  )
}

// `Visibility` includes `direct` and `draft` for type-shape parity with the
// server enum, but the picker only ships `public | unlisted | private`. Map
// the out-of-set values to `public` for label/icon lookup so a fork-emitted
// scope (e.g. Glitch-soc's `local`, already coerced to `public` upstream by
// `coerceVisibility`) and `direct`/`draft` collapse to a sane default
// rather than crashing the toolbar.
function scopeKey(v: string): 'public' | 'unlisted' | 'private' {
  return v === 'unlisted' || v === 'private' ? v : 'public'
}

function busyLabelFor(mode: string, compressionProgress: number): string | null {
  switch (mode) {
    case 'posting':
      return 'Posting…'
    case 'uploading':
      return 'Uploading media…'
    case 'compressing':
      return `Compressing video ${Math.round(compressionProgress * 100)}%…`
    case 'resizing':
      return 'Preparing media…'
    default:
      return null
  }
}
