@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)

package com.ponzischeme89.memby.ui.search

import android.app.Activity
import android.content.Intent
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled
import androidx.compose.material.icons.filled.SentimentVerySatisfied
import androidx.compose.material.icons.filled.TheaterComedy
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.utf16CodePoint
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.imageLoader
import kotlinx.coroutines.flow.distinctUntilChanged
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
import com.ponzischeme89.memby.ui.FocusScaleContainer
import com.ponzischeme89.memby.ui.PosterGridCard
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
import com.ponzischeme89.memby.ui.theme.MembySurface

private val PaneBackground: Color get() = MembySurfaceRaised
private val KeyIdle = Color(0xFF1A2129)
private val KeyFocused: Color get() = MembyAccent
private val KeyLabel = Color(0xFFE8EDF1)
private val KeyLabelFocused = Color(0xFF06240A)
private val Heading = Color(0xFFF2F5F7)
private val Muted = Color(0xFFB7C0C8)
private val Accent: Color get() = MembyAccent

/**
 * Six columns of six, then a row of actions. Rectangular on purpose: every key has a
 * neighbour directly above and below it, so D-pad movement is predictable in a way a
 * QWERTY layout with ragged rows never is.
 */
private val KeyboardRows = listOf(
    "ABCDEF",
    "GHIJKL",
    "MNOPQR",
    "STUVWX",
    "YZ0123",
    "456789",
)

/** Left pane share of the width. Wide enough for six comfortable keys at TV distance. */
private const val KEYBOARD_PANE_FRACTION = 0.35f

/** Posters prefetched as soon as results land, so the first visible row is never blank. */
private const val PREFETCHED_POSTERS = 8

/**
 * How far from the end of a genre the next page is asked for, in rows.
 *
 * Two rows rather than one: a D-pad walks a row at a time and the request has to be in
 * flight before the viewer arrives at the bottom, or the scroll stops dead and the shelf
 * reads as having ended.
 */
private const val LOAD_MORE_ROWS_AHEAD = 2

/**
 * Full-screen search: keyboard on the left, results on the right, updating as you type.
 *
 * The two panes are the whole point — the results never disappear behind a keyboard, and
 * no query is ever "submitted". Everything below is in service of that: focus that moves
 * predictably between the panes, and a result pane that updates without flashing.
 */
@Composable
fun SearchScreen(
    navigationFocusRequester: FocusRequester,
    contentFocusRequester: FocusRequester,
    discoveryItems: List<BaseItem>,
    returnFocusItemId: String?,
    returnFocusRequester: FocusRequester,
    initialQuery: String? = null,
    onInitialQueryConsumed: () -> Unit = {},
    onItemFocused: (BaseItem) -> Unit,
    onItemSelected: (BaseItem) -> Unit,
    onContentFocused: () -> Unit,
    onExit: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val repository = ServiceLocator.repository
    val viewModel: SearchViewModel = viewModel(
        factory = remember(repository) { SearchViewModelFactory(repository) },
    )
    val state by viewModel.state.collectAsStateWithLifecycle()

    LaunchedEffect(discoveryItems) { viewModel.setDiscoveryItems(discoveryItems) }
    LaunchedEffect(initialQuery) {
        initialQuery?.takeIf { it.isNotBlank() }?.let {
            viewModel.onQueryChanged(it)
            onInitialQueryConsumed()
        }
    }

    val resultsEntry = remember { FocusRequester() }
    // The rail and the screen agree on one entry target: Search opens on the keyboard,
    // just as browse destinations open on their primary content action.
    val keyboardEntry = contentFocusRequester
    // Where "left out of the results" lands. It follows the last key used, so coming back
    // returns the viewer to where they were typing rather than to a fixed corner.
    val keyboardReturn = remember { FocusRequester() }
    var lastKeyIndex by remember { mutableIntStateOf(0) }
    var focusInResults by remember { mutableStateOf(false) }
    var restoreGenreChipFocus by remember { mutableStateOf(false) }
    val hasResultsTarget = when {
        state.errorMessage != null && state.results.isEmpty() -> true
        state.isDiscovery -> discoveryItems.isNotEmpty() ||
            state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
        else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
    }

    LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }

    // A genre chip unmounts the moment its shelf opens — the whole discovery pane goes with
    // it — so the focus it was holding belongs to nothing unless something takes it. The
    // grid claims it as soon as there is a card to land on, which is where the viewer is
    // already looking; a genre that came back empty or failed hands the remote back to the
    // keyboard rather than leaving a television with nothing focused at all.
    LaunchedEffect(state.genre, state.results.isEmpty(), state.isLoading, state.errorMessage) {
        if (state.genre == null) return@LaunchedEffect
        when {
            state.results.isNotEmpty() || state.errorMessage != null ->
                if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
            !state.isLoading -> runCatching { keyboardReturn.requestFocus() }
            // Still loading: the branch above takes it the moment the first page lands.
            else -> Unit
        }
    }

    LaunchedEffect(state.genre, restoreGenreChipFocus) {
        if (state.genre == null && restoreGenreChipFocus) {
            // clearGenre remounts discovery and its genre chips. Wait for the first chip's
            // focus node before handing the remote back to where this level was opened.
            kotlinx.coroutines.delay(16L)
            if (runCatching { resultsEntry.requestFocus() }.isSuccess) focusInResults = true
            restoreGenreChipFocus = false
        }
    }

    val closeGenre: () -> Unit = {
        restoreGenreChipFocus = true
        viewModel.clearGenre()
    }

    BackHandler {
        when {
            // A genre is its own level, between discovery and leaving Search. Close that
            // level first regardless of which card currently owns focus.
            state.genre != null -> closeGenre()
            // Results → keyboard → clear → leave. Each press does one obvious thing, and
            // none of them can loop back to the previous state.
            focusInResults -> runCatching { keyboardReturn.requestFocus() }
                .onFailure { runCatching { keyboardEntry.requestFocus() } }
            state.query.isNotEmpty() -> viewModel.clearQuery()
            else -> onExit()
        }
    }

    Row(
        modifier = modifier
            .fillMaxSize()
            // A USB keyboard, or a phone remote app sending key events, feeds exactly the
            // same query state as the on-screen keys. Only printable characters and
            // backspace are consumed; D-pad and Back must fall through untouched.
            .onPreviewKeyEvent { event ->
                if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
                val code = event.utf16CodePoint
                when {
                    code == BACKSPACE_CODE -> {
                        viewModel.backspace(); true
                    }
                    code >= FIRST_PRINTABLE_CODE -> {
                        viewModel.appendToQuery(code.toChar().toString()); true
                    }
                    else -> false
                }
            },
    ) {
        SearchPane(
            state = state,
            navigationFocusRequester = navigationFocusRequester,
            resultsEntry = resultsEntry,
            keyboardEntry = keyboardEntry,
            keyboardReturn = keyboardReturn,
            lastKeyIndex = lastKeyIndex,
            hasResultsTarget = hasResultsTarget,
            onKeyFocused = { index ->
                lastKeyIndex = index
                focusInResults = false
                onContentFocused()
            },
            onCharacter = viewModel::appendToQuery,
            onBackspace = viewModel::backspace,
            onClear = viewModel::clearQuery,
            onVoiceResult = viewModel::onQueryChanged,
            modifier = Modifier
                .fillMaxWidth(KEYBOARD_PANE_FRACTION)
                .fillMaxHeight(),
        )
        ResultsPane(
            state = state,
            resultsEntry = resultsEntry,
            keyboardReturn = keyboardReturn,
            returnFocusItemId = returnFocusItemId,
            returnFocusRequester = returnFocusRequester,
            discoveryItems = discoveryItems,
            onItemFocused = { item ->
                focusInResults = true
                onContentFocused()
                onItemFocused(item)
            },
            onItemSelected = onItemSelected,
            onRetry = viewModel::retry,
            onRequest = viewModel::request,
            onSuggestionSelected = viewModel::onQueryChanged,
            onGenreSelected = viewModel::onGenreSelected,
            onBackFromGenre = closeGenre,
            onLoadMore = viewModel::loadMore,
            modifier = Modifier.fillMaxHeight(),
        )
    }
}

@Composable
private fun SearchPane(
    state: SearchUiState,
    navigationFocusRequester: FocusRequester,
    resultsEntry: FocusRequester,
    keyboardEntry: FocusRequester,
    keyboardReturn: FocusRequester,
    lastKeyIndex: Int,
    hasResultsTarget: Boolean,
    onKeyFocused: (Int) -> Unit,
    onCharacter: (String) -> Unit,
    onBackspace: () -> Unit,
    onClear: () -> Unit,
    onVoiceResult: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier
            .background(PaneBackground)
            .padding(start = 28.dp, end = 20.dp, top = 24.dp, bottom = 16.dp),
    ) {
        Text("Search", color = Heading, fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
        Spacer(Modifier.height(10.dp))
        QueryField(
            query = state.query,
            loading = state.isLoading,
            onVoiceResult = onVoiceResult,
        )
        Spacer(Modifier.height(14.dp))
        TvKeyboard(
            navigationFocusRequester = navigationFocusRequester,
            resultsEntry = resultsEntry,
            keyboardEntry = keyboardEntry,
            keyboardReturn = keyboardReturn,
            lastKeyIndex = lastKeyIndex,
            hasResultsTarget = hasResultsTarget,
            onKeyFocused = onKeyFocused,
            onCharacter = onCharacter,
            onBackspace = onBackspace,
            onClear = onClear,
        )
    }
}

/**
 * The query as typed, plus the two things that belong beside it: a quiet progress dot
 * while a search is in flight, and voice input when the device offers it.
 */
@Composable
private fun QueryField(
    query: String,
    loading: Boolean,
    onVoiceResult: (String) -> Unit,
) {
    val context = LocalContext.current
    val voiceAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
    val voiceLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.StartActivityForResult(),
    ) { result ->
        if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
        val spoken = result.data
            ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
            ?.firstOrNull()
            ?.trim()
        // Replaces rather than appends: dictating a title after typing half of another
        // one almost always means "no, this instead".
        if (!spoken.isNullOrEmpty()) onVoiceResult(spoken)
    }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(10.dp))
            .background(Color(0xFF151C23))
            .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(10.dp))
            .padding(horizontal = 12.dp, vertical = 9.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(
            Icons.Default.Search,
            contentDescription = null,
            tint = if (query.isEmpty()) Muted else Accent,
            modifier = Modifier.size(18.dp),
        )
        Spacer(Modifier.width(10.dp))
        Text(
            text = query.ifEmpty { "Search movies, shows and episodes" },
            color = if (query.isEmpty()) Muted else Heading,
            // Fixed size rather than shrinking as the query grows: readable at three
            // metres matters more than fitting a long query on one line.
            fontSize = if (query.isEmpty()) 15.sp else 17.sp,
            fontWeight = if (query.isEmpty()) FontWeight.Normal else FontWeight.SemiBold,
            maxLines = 1,
            // A long query scrolls off the *start*, so the letters just typed stay visible.
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.weight(1f),
        )
        if (loading) {
            Spacer(Modifier.width(8.dp))
            LoadingDot()
        }
        if (voiceAvailable) {
            Spacer(Modifier.width(8.dp))
            FocusScaleContainer(
                onFocused = {},
                onClick = {
                    val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
                        putExtra(
                            RecognizerIntent.EXTRA_LANGUAGE_MODEL,
                            RecognizerIntent.LANGUAGE_MODEL_FREE_FORM,
                        )
                        putExtra(RecognizerIntent.EXTRA_PROMPT, "Say a title")
                    }
                    // A device can advertise recognition and still have nothing to
                    // launch; failing silently beats crashing the search screen.
                    runCatching { voiceLauncher.launch(intent) }
                },
                contentDescription = "Search by voice",
                modifier = Modifier.clip(RoundedCornerShape(8.dp)),
            ) { focused ->
                Icon(
                    Icons.Default.Mic,
                    contentDescription = null,
                    tint = if (focused) KeyLabelFocused else KeyLabel,
                    modifier = Modifier
                        .background(if (focused) KeyFocused else KeyIdle)
                        .padding(6.dp)
                        .size(18.dp),
                )
            }
        }
    }
}

@Composable
private fun TvKeyboard(
    navigationFocusRequester: FocusRequester,
    resultsEntry: FocusRequester,
    keyboardEntry: FocusRequester,
    keyboardReturn: FocusRequester,
    lastKeyIndex: Int,
    hasResultsTarget: Boolean,
    onKeyFocused: (Int) -> Unit,
    onCharacter: (String) -> Unit,
    onBackspace: () -> Unit,
    onClear: () -> Unit,
) {
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        KeyboardRows.forEachIndexed { rowIndex, row ->
            Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                row.forEachIndexed { columnIndex, character ->
                    val index = rowIndex * KEYBOARD_COLUMNS + columnIndex
                    KeyboardKey(
                        label = character.toString(),
                        contentDescription = "Type ${character}",
                        onClick = { onCharacter(character.toString()) },
                        onFocused = { onKeyFocused(index) },
                        modifier = Modifier
                            .weight(1f)
                            // Explicit edges. Left of the first column is the navigation
                            // rail, right of the last is the results grid: focus can
                            // always get out of this pane, and never falls off it.
                            .focusProperties {
                                if (columnIndex == 0) left = navigationFocusRequester
                                if (columnIndex == KEYBOARD_COLUMNS - 1) {
                                    right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
                                }
                            }
                            .then(
                                if (index == 0) Modifier.focusRequester(keyboardEntry) else Modifier,
                            )
                            .then(
                                if (index == lastKeyIndex) {
                                    Modifier.focusRequester(keyboardReturn)
                                } else {
                                    Modifier
                                },
                            ),
                    )
                }
            }
        }

        Spacer(Modifier.height(2.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
            ActionKey(
                icon = Icons.Default.SpaceBar,
                label = "Space",
                contentDescription = "Insert a space",
                onClick = { onCharacter(" ") },
                onFocused = { onKeyFocused(ACTION_ROW_INDEX) },
                modifier = Modifier
                    .weight(2f)
                    .focusProperties { left = navigationFocusRequester }
                    .then(
                        if (lastKeyIndex == ACTION_ROW_INDEX) {
                            Modifier.focusRequester(keyboardReturn)
                        } else {
                            Modifier
                        },
                    ),
            )
            ActionKey(
                icon = Icons.AutoMirrored.Filled.Backspace,
                label = "Delete",
                contentDescription = "Delete the last character",
                onClick = onBackspace,
                onFocused = { onKeyFocused(ACTION_ROW_INDEX + 1) },
                modifier = Modifier
                    .weight(2f)
                    .then(
                        if (lastKeyIndex == ACTION_ROW_INDEX + 1) {
                            Modifier.focusRequester(keyboardReturn)
                        } else {
                            Modifier
                        },
                    ),
            )
            ActionKey(
                icon = Icons.Default.Close,
                label = "Clear",
                contentDescription = "Clear the whole query",
                onClick = onClear,
                onFocused = { onKeyFocused(ACTION_ROW_INDEX + 2) },
                modifier = Modifier
                    .weight(2f)
                    .focusProperties {
                        right = if (hasResultsTarget) resultsEntry else FocusRequester.Cancel
                    }
                    .then(
                        if (lastKeyIndex == ACTION_ROW_INDEX + 2) {
                            Modifier.focusRequester(keyboardReturn)
                        } else {
                            Modifier
                        },
                    ),
            )
        }
    }
}

@Composable
private fun KeyboardKey(
    label: String,
    contentDescription: String,
    onClick: () -> Unit,
    onFocused: () -> Unit,
    modifier: Modifier = Modifier,
) {
    FocusScaleContainer(
        onFocused = onFocused,
        onClick = onClick,
        contentDescription = contentDescription,
        modifier = modifier.clip(RoundedCornerShape(8.dp)),
    ) { focused ->
        Box(
            Modifier
                .fillMaxWidth()
                // 44dp of target at six columns across a third of a 1080p screen: large
                // enough to hit reliably while glancing at the results, not the keyboard.
                .height(36.dp)
                .background(if (focused) KeyFocused else KeyIdle),
            contentAlignment = Alignment.Center,
        ) {
            Text(
                label,
                color = if (focused) KeyLabelFocused else KeyLabel,
                fontSize = 16.sp,
                fontWeight = FontWeight.SemiBold,
            )
        }
    }
}

@Composable
private fun ActionKey(
    icon: ImageVector,
    label: String,
    contentDescription: String,
    onClick: () -> Unit,
    onFocused: () -> Unit,
    modifier: Modifier = Modifier,
) {
    FocusScaleContainer(
        onFocused = onFocused,
        onClick = onClick,
        contentDescription = contentDescription,
        modifier = modifier.clip(RoundedCornerShape(8.dp)),
    ) { focused ->
        Row(
            Modifier
                .fillMaxWidth()
                .height(36.dp)
                .background(if (focused) KeyFocused else KeyIdle),
            horizontalArrangement = Arrangement.Center,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                icon,
                contentDescription = null,
                tint = if (focused) KeyLabelFocused else KeyLabel,
                modifier = Modifier.size(17.dp),
            )
            Spacer(Modifier.width(7.dp))
            Text(
                label,
                color = if (focused) KeyLabelFocused else KeyLabel,
                fontSize = 13.sp,
                fontWeight = FontWeight.SemiBold,
                maxLines = 1,
            )
        }
    }
}

/** A quiet pulse beside the query. Deliberately not a full-screen spinner. */
@Composable
private fun LoadingDot() {
    val alpha = rememberInfiniteTransition(label = "search-loading").animateFloat(
        initialValue = 0.25f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(tween(620), RepeatMode.Reverse),
        label = "search-loading-alpha",
    )
    // Read in the draw lambda: a spinner beside the query must not recompose the pane
    // that holds the keyboard.
    Box(
        Modifier
            .size(9.dp)
            .drawBehind { drawCircle(color = Accent.copy(alpha = alpha.value)) }
            .semantics { contentDescription = "Searching" },
    )
}

@Composable
private fun ResultsPane(
    state: SearchUiState,
    resultsEntry: FocusRequester,
    keyboardReturn: FocusRequester,
    returnFocusItemId: String?,
    returnFocusRequester: FocusRequester,
    discoveryItems: List<BaseItem>,
    onItemFocused: (BaseItem) -> Unit,
    onItemSelected: (BaseItem) -> Unit,
    onRetry: () -> Unit,
    onRequest: (GatewayRequestCandidate) -> Unit,
    onSuggestionSelected: (String) -> Unit,
    onGenreSelected: (String) -> Unit,
    onBackFromGenre: () -> Unit,
    onLoadMore: () -> Unit,
    modifier: Modifier = Modifier,
) {
    BoxWithConstraints(
        modifier = modifier
            .fillMaxWidth()
            .padding(start = 28.dp, end = 32.dp, top = 28.dp, bottom = 12.dp),
    ) {
        val columns = if (maxWidth > 760.dp) 5 else 4
        val cardWidth = ((maxWidth - CARD_SPACING * (columns - 1)) / columns)
            .coerceIn(120.dp, 200.dp)

        // Discovery, results, error and "no matches" all share this pane. Only the
        // heading and the item source change, so the grid never unmounts and remounts.
        val showingDiscovery = state.isDiscovery
        val items = if (showingDiscovery) discoveryItems else state.results

        Column(Modifier.fillMaxSize()) {
            ResultsHeading(
                state = state,
                showingDiscovery = showingDiscovery,
                onBackFromGenre = onBackFromGenre,
            )
            val genres = state.suggestions.filter { it.kind == SearchSuggestion.Kind.GENRE }
            val recent = state.suggestions.filter { it.kind == SearchSuggestion.Kind.RECENT }
            if (showingDiscovery && genres.isNotEmpty()) {
                Spacer(Modifier.height(8.dp))
                GenreTiles(
                    genres = genres,
                    resultsEntry = resultsEntry,
                    keyboardReturn = keyboardReturn,
                    // Not onSuggestionSelected: a genre opens the shelf of titles that are
                    // in it, where running its name through search matched a film called
                    // Drama and missed most of the drama.
                    onSelected = onGenreSelected,
                )
            }
            if (showingDiscovery && recent.isNotEmpty()) {
                Spacer(Modifier.height(8.dp))
                Text(
                    "RECENT SEARCHES",
                    color = Muted,
                    fontSize = 11.sp,
                    fontWeight = FontWeight.Bold,
                    letterSpacing = 0.8.sp,
                )
                Spacer(Modifier.height(4.dp))
                SuggestionChips(
                    suggestions = recent,
                    resultsEntry = null,
                    keyboardReturn = keyboardReturn,
                    onSelected = onSuggestionSelected,
                )
            }
            Spacer(Modifier.height(12.dp))

            when {
                state.errorMessage != null && state.results.isEmpty() -> SearchError(
                    message = state.errorMessage,
                    onRetry = onRetry,
                    resultsEntry = resultsEntry,
                    keyboardReturn = keyboardReturn,
                )
                !showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() ->
                    RequestOptions(
                        state = state,
                        resultsEntry = resultsEntry,
                        keyboardReturn = keyboardReturn,
                        onRequest = onRequest,
                        modifier = Modifier.weight(1f),
                    )
                items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
                else -> ResultsGrid(
                    items = items,
                    columns = columns,
                    cardWidth = cardWidth,
                    resultsEntry = resultsEntry,
                    keyboardReturn = keyboardReturn,
                    returnFocusItemId = returnFocusItemId,
                    returnFocusRequester = returnFocusRequester,
                    onItemFocused = onItemFocused,
                    onItemSelected = onItemSelected,
                    // Only a genre pages. A search is one response, and asking the grid to
                    // watch for the end of a list that has no more behind it is a scroll
                    // listener running for nothing.
                    paging = state.genre != null && (state.canLoadMore || state.isLoadingMore),
                    loadingMore = state.isLoadingMore,
                    onLoadMore = onLoadMore,
                )
            }
        }
    }
}

@Composable
private fun ResultsHeading(
    state: SearchUiState,
    showingDiscovery: Boolean,
    onBackFromGenre: () -> Unit,
) {
    val title = when {
        showingDiscovery -> "Browse your library"
        // A genre says what it is. It was never searched for, so calling it a search result
        // would misdescribe both where the titles came from and how to get out of it.
        state.genre != null -> state.genre
        state.isEmptyResult -> "Search results — no matches"
        else -> "Search results for “${state.query.trim()}”"
    }
    Row(verticalAlignment = Alignment.CenterVertically) {
        if (state.genre != null) {
            FocusScaleContainer(
                onFocused = {},
                onClick = onBackFromGenre,
                contentDescription = "Back to search",
                modifier = Modifier.size(40.dp).clip(RoundedCornerShape(10.dp)),
            ) { focused ->
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .background(if (focused) Color.White else KeyIdle),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(
                        Icons.AutoMirrored.Filled.ArrowBack,
                        contentDescription = null,
                        tint = if (focused) KeyLabelFocused else Heading,
                        modifier = Modifier.size(21.dp),
                    )
                }
            }
            Spacer(Modifier.width(12.dp))
        }
        Text(
            title,
            color = Heading,
            fontSize = 22.sp,
            fontWeight = FontWeight.SemiBold,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
            modifier = Modifier.weight(1f, fill = false),
        )
        // The count belongs to a search, where it is the whole answer. On a genre it would
        // be the number of cards fetched so far, which grows as the viewer scrolls and
        // describes the paging rather than the library.
        if (state.genre == null && !showingDiscovery && state.results.isNotEmpty()) {
            Spacer(Modifier.width(10.dp))
            Text("${state.results.size}", color = Muted, fontSize = 16.sp)
        }
    }
}

@Composable
private fun ResultsGrid(
    items: List<BaseItem>,
    columns: Int,
    cardWidth: Dp,
    resultsEntry: FocusRequester,
    keyboardReturn: FocusRequester,
    returnFocusItemId: String?,
    returnFocusRequester: FocusRequester,
    onItemFocused: (BaseItem) -> Unit,
    onItemSelected: (BaseItem) -> Unit,
    paging: Boolean = false,
    loadingMore: Boolean = false,
    onLoadMore: () -> Unit = {},
) {
    val context = LocalContext.current
    val density = LocalDensity.current
    val gridState = rememberLazyGridState()

    // Infinite scroll. Read in a snapshotFlow rather than from the composable body: the
    // last visible index changes on every frame of a scroll, and reading it up here would
    // recompose the whole grid the entire way down a genre. The next page is asked for a
    // row early — the request has to be in flight before the viewer arrives at the end, or
    // the scroll stops dead while they wait for it.
    if (paging) {
        LaunchedEffect(gridState, items.size, columns) {
            snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
                .distinctUntilChanged()
                .collect { last ->
                    if (last >= items.size - columns * LOAD_MORE_ROWS_AHEAD) onLoadMore()
                }
        }
    }

    // Warm the first screenful so the grid does not fill in card by card. Keyed on the
    // ids rather than the list, so an unchanged result set never re-fetches.
    val prefetchKey = remember(items) { items.take(PREFETCHED_POSTERS).joinToString("|") { it.id } }
    LaunchedEffect(prefetchKey, cardWidth) {
        val repo = ServiceLocator.repository
        val widthPx = with(density) { cardWidth.roundToPx() }.coerceIn(180, 720)
        items.take(PREFETCHED_POSTERS).forEach { item ->
            val url = repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx)
            ?: return@forEach
            context.imageLoader.execute(
                ImageRequest.Builder(context)
                    .data(url)
                    .size(widthPx, (widthPx * 3f / 2f).toInt())
                    .allowHardware(true)
                    .crossfade(false)
                    .build(),
            )
        }
    }

    LazyVerticalGrid(
        columns = GridCells.Fixed(columns),
        state = gridState,
        horizontalArrangement = Arrangement.spacedBy(CARD_SPACING),
        verticalArrangement = Arrangement.spacedBy(18.dp),
        modifier = Modifier
            .fillMaxSize(),
    ) {
        // itemsIndexed, not items + indexOf: BaseItem is a data class, so indexOf would
        // run a deep equals per card per composition on the app's hottest new screen.
        itemsIndexed(items, key = { _, item -> item.id }, contentType = { _, _ -> "search-result" }) { index, item ->
            PosterGridCard(
                item = item,
                width = cardWidth,
                onFocused = { onItemFocused(item) },
                onClick = { onItemSelected(item) },
                onLongClick = { onItemSelected(item) },
                modifier = Modifier
                    .then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
                    .then(
                        if (item.id == returnFocusItemId) {
                            Modifier.focusRequester(returnFocusRequester)
                        } else {
                            Modifier
                        },
                    )
                    // Leftmost column goes back to the keyboard rather than nowhere.
                    .focusProperties {
                        if (index % columns == 0) left = keyboardReturn
                    },
            )
        }
        if (loadingMore) {
            // A full-width row rather than a card-shaped placeholder: a skeleton card is
            // something a remote tries to focus, and there is nothing there to open.
            item(span = { GridItemSpan(maxLineSpan) }, contentType = "search-paging") {
                Row(
                    modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    LoadingDot()
                    Spacer(Modifier.width(10.dp))
                    Text("Loading more", color = Muted, fontSize = 13.sp)
                }
            }
        }
    }
}

@Composable
private fun RequestOptions(
    state: SearchUiState,
    resultsEntry: FocusRequester,
    keyboardReturn: FocusRequester,
    onRequest: (GatewayRequestCandidate) -> Unit,
    modifier: Modifier = Modifier,
    previewArtwork: ImageBitmap? = null,
) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .clip(RoundedCornerShape(16.dp))
            .background(Color(0xFF11171C))
            .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
            .padding(20.dp),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Box(
                Modifier
                    .size(42.dp)
                    .clip(RoundedCornerShape(11.dp))
                    .background(Accent.copy(alpha = 0.16f)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(Icons.Default.Add, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
            }
            Spacer(Modifier.width(13.dp))
            Column(Modifier.weight(1f)) {
                Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
                Text(
                    "We found a few close matches. Choose the exact movie or series you want added.",
                    color = Muted,
                    fontSize = 13.sp,
                    maxLines = 2,
                )
            }
            Text(
                "${state.requestCandidates.take(6).size} MATCH${if (state.requestCandidates.size == 1) "" else "ES"}",
                color = Accent,
                fontSize = 11.sp,
                fontWeight = FontWeight.Bold,
            )
        }
        Spacer(Modifier.height(16.dp))
        LazyVerticalGrid(
            columns = GridCells.Fixed(2),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
            modifier = Modifier.weight(1f),
        ) {
            itemsIndexed(
                state.requestCandidates.take(6),
                key = { _, candidate -> "${candidate.mediaType}:${candidate.foreignId}" },
            ) { index, candidate ->
                RequestCandidateCard(
                    candidate = candidate,
                    busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}",
                    onRequest = { onRequest(candidate) },
                    previewArtwork = previewArtwork,
                    modifier = Modifier
                        .then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
                        .focusProperties { if (index % 2 == 0) left = keyboardReturn },
                )
            }
        }
        state.requestMessage?.let { message ->
            val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
            Spacer(Modifier.height(12.dp))
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .clip(RoundedCornerShape(10.dp))
                    .background(statusColor.copy(alpha = 0.12f))
                    .padding(horizontal = 14.dp, vertical = 10.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Icon(
                    if (state.requestMessageIsError) Icons.Default.Close else Icons.Default.CheckCircle,
                    contentDescription = null,
                    tint = statusColor,
                    modifier = Modifier.size(18.dp),
                )
                Spacer(Modifier.width(9.dp))
                Text(message, color = KeyLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium)
            }
        }
    }
}

@Composable
private fun RequestCandidateCard(
    candidate: GatewayRequestCandidate,
    busy: Boolean,
    onRequest: () -> Unit,
    modifier: Modifier = Modifier,
    previewArtwork: ImageBitmap? = null,
) {
    val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
    val actionLabel = when {
        candidate.alreadyAdded -> "REQUESTED"
        busy -> "REQUESTING…"
        else -> "REQUEST"
    }
    FocusScaleContainer(
        onFocused = {},
        onClick = { if (!candidate.alreadyAdded && !busy) onRequest() },
        contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
        modifier = modifier,
    ) { focused ->
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(140.dp)
                .clip(RoundedCornerShape(12.dp))
                .background(
                    when {
                        focused -> Color(0xFF203228)
                        candidate.alreadyAdded -> Color(0xFF17231C)
                        else -> Color(0xFF1A2127)
                    },
                )
                .border(
                    width = if (focused) 2.dp else 1.dp,
                    color = when {
                        focused -> Accent
                        candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
                        else -> Color.White.copy(alpha = 0.10f)
                    },
                    shape = RoundedCornerShape(12.dp),
                )
                .padding(9.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                modifier = Modifier
                    .width(76.dp)
                    .fillMaxHeight()
                    .clip(RoundedCornerShape(8.dp))
                    .background(Color(0xFF0B0F12)),
                contentAlignment = Alignment.Center,
            ) {
                if (previewArtwork != null) {
                    Image(
                        bitmap = previewArtwork,
                        contentDescription = null,
                        contentScale = ContentScale.Crop,
                        modifier = Modifier.fillMaxSize(),
                    )
                } else if (candidate.posterUrl.isNotBlank()) {
                    AsyncImage(
                        model = candidate.posterUrl,
                        contentDescription = null,
                        contentScale = ContentScale.Crop,
                        modifier = Modifier.fillMaxSize(),
                    )
                } else {
                    Icon(
                        if (mediaLabel == "MOVIE") Icons.Default.Movie else Icons.Default.LiveTv,
                        contentDescription = null,
                        tint = Muted.copy(alpha = 0.65f),
                        modifier = Modifier.size(30.dp),
                    )
                }
            }
            Spacer(Modifier.width(12.dp))
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Text(mediaLabel, color = Accent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
                    candidate.year.takeIf { it > 0 }?.let { year ->
                        Text("  •  $year", color = Muted, fontSize = 11.sp)
                    }
                }
                Text(
                    candidate.title,
                    color = Heading,
                    fontSize = 16.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis,
                )
                if (candidate.overview.isNotBlank()) {
                    Text(
                        candidate.overview,
                        color = Muted,
                        fontSize = 11.sp,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                    )
                }
                Spacer(Modifier.weight(1f))
                Row(verticalAlignment = Alignment.CenterVertically) {
                    if (candidate.alreadyAdded) {
                        Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
                        Spacer(Modifier.width(5.dp))
                    }
                    Text(
                        actionLabel,
                        color = if (candidate.alreadyAdded || focused) Accent else KeyLabel,
                        fontSize = 11.sp,
                        fontWeight = FontWeight.Bold,
                    )
                    if (!candidate.alreadyAdded && !busy) Text("  →", color = Accent, fontSize = 13.sp)
                }
            }
        }
    }
}

/** Stable, data-free fixture used by the screenshot test and design reviews. */
@Composable
internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
    val firstCardFocus = remember { FocusRequester() }
    LaunchedEffect(Unit) { runCatching { firstCardFocus.requestFocus() } }
    Box(
        Modifier
            .fillMaxSize()
            .background(MembySurface)
            .padding(28.dp),
    ) {
        RequestOptions(
            state = SearchUiState(
                query = "the last voyage",
                hasSearched = true,
                requestCandidates = listOf(
                    GatewayRequestCandidate(
                        mediaType = "movie", foreignId = 101, title = "The Last Voyage",
                        year = 2026,
                        overview = "A lone cartographer follows a signal beyond the edge of every known map.",
                    ),
                    GatewayRequestCandidate(
                        mediaType = "series", foreignId = 102, title = "Voyagers",
                        year = 2024,
                        overview = "Six strangers wake aboard a ship already halfway to another world.",
                    ),
                    GatewayRequestCandidate(
                        mediaType = "movie", foreignId = 103, title = "The Long Way Home",
                        year = 2022,
                        overview = "A rescue pilot gets one final chance to cross the storm.",
                    ),
                    GatewayRequestCandidate(
                        mediaType = "series", foreignId = 104, title = "Beyond the Horizon",
                        year = 2025,
                        overview = "An observatory discovers that tomorrow is broadcasting back.",
                        alreadyAdded = true,
                    ),
                ),
            ),
            resultsEntry = firstCardFocus,
            keyboardReturn = remember { FocusRequester() },
            onRequest = {},
            previewArtwork = previewArtwork,
        )
    }
}

private val GenreColors = listOf(
    Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
    Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
)

private fun genreIcon(label: String): ImageVector = when {
    label.contains("comedy", true) -> Icons.Default.TheaterComedy
    label.contains("music", true) -> Icons.Default.LiveTv
    label.contains("children", true) || label.contains("family", true) -> Icons.Default.SentimentVerySatisfied
    label.contains("sport", true) -> Icons.Default.PlayCircleFilled
    label.contains("document", true) -> Icons.Default.Movie
    else -> Icons.Default.AutoAwesome
}

@Composable
private fun GenreTiles(
    genres: List<SearchSuggestion>,
    resultsEntry: FocusRequester,
    keyboardReturn: FocusRequester,
    onSelected: (String) -> Unit,
) {
    LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        rowItemsIndexed(genres.take(6), key = { _, item -> item.label }) { index, genre ->
            val color = GenreColors[index % GenreColors.size]
            FocusScaleContainer(
                onFocused = {},
                onClick = { onSelected(genre.label) },
                contentDescription = "Search the ${genre.label} genre",
                modifier = Modifier
                    .width(126.dp)
                    .height(72.dp)
                    .clip(RoundedCornerShape(10.dp))
                    .then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
                    .focusProperties { if (index == 0) left = keyboardReturn },
            ) { focused ->
                Box(
                    Modifier
                        .fillMaxSize()
                        .background(if (focused) color.copy(alpha = 0.95f) else color),
                ) {
                    Icon(
                        genreIcon(genre.label),
                        contentDescription = null,
                        tint = Color.White.copy(alpha = 0.9f),
                        modifier = Modifier
                            .align(Alignment.TopEnd)
                            .padding(10.dp)
                            .size(30.dp),
                    )
                    Text(
                        genre.label,
                        color = Color.White,
                        fontSize = 14.sp,
                        fontWeight = FontWeight.Bold,
                        maxLines = 2,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.align(Alignment.BottomStart).padding(10.dp),
                    )
                }
            }
        }
    }
}

@Composable
private fun SuggestionChips(
    suggestions: List<SearchSuggestion>,
    resultsEntry: FocusRequester?,
    keyboardReturn: FocusRequester,
    onSelected: (String) -> Unit,
) {
    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
        suggestions.take(6).forEachIndexed { index, suggestion ->
            FocusScaleContainer(
                onFocused = {},
                onClick = { onSelected(suggestion.label) },
                contentDescription = when (suggestion.kind) {
                    SearchSuggestion.Kind.RECENT -> "Search again for ${suggestion.label}"
                    SearchSuggestion.Kind.GENRE -> "Search the ${suggestion.label} genre"
                },
                modifier = Modifier
                    .clip(RoundedCornerShape(999.dp))
                    .then(if (index == 0 && resultsEntry != null) Modifier.focusRequester(resultsEntry) else Modifier)
                    .focusProperties { if (index == 0) left = keyboardReturn },
            ) { focused ->
                Text(
                    suggestion.label.uppercase(),
                    color = if (focused) KeyLabelFocused else KeyLabel,
                    fontSize = 11.sp,
                    fontWeight = FontWeight.Bold,
                    maxLines = 1,
                    modifier = Modifier
                        .background(if (focused) KeyFocused else KeyIdle)
                        .padding(horizontal = 12.dp, vertical = 7.dp),
                )
            }
        }
    }
}

@Composable
private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean) {
    val message = when {
        showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
        // A genre was never searched for, and saying so while a shelf opens is the one
        // moment the difference is invisible on screen.
        state.genre != null && state.isLoading -> "Loading ${state.genre}…"
        state.genre != null -> "Nothing in this library is tagged ${state.genre}."
        state.isLoading -> "Searching…"
        state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
        else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
    }
    Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
}

@Composable
private fun SearchError(
    message: String,
    onRetry: () -> Unit,
    resultsEntry: FocusRequester,
    keyboardReturn: FocusRequester,
) {
    Column(Modifier.padding(top = 36.dp)) {
        Text(message, color = Heading, fontSize = 18.sp)
        Spacer(Modifier.height(14.dp))
        FocusScaleContainer(
            onFocused = {},
            onClick = onRetry,
            contentDescription = "Try the search again",
            modifier = Modifier
                .clip(RoundedCornerShape(10.dp))
                .focusRequester(resultsEntry)
                .focusProperties { left = keyboardReturn },
        ) { focused ->
            Text(
                "Try again",
                color = if (focused) KeyLabelFocused else KeyLabel,
                fontSize = 16.sp,
                fontWeight = FontWeight.SemiBold,
                modifier = Modifier
                    .background(if (focused) KeyFocused else KeyIdle)
                    .padding(horizontal = 22.dp, vertical = 11.dp),
            )
        }
    }
}

private val CARD_SPACING = 16.dp
private const val KEYBOARD_COLUMNS = 6
private const val ACTION_ROW_INDEX = 36
private const val BACKSPACE_CODE = 8
private const val FIRST_PRINTABLE_CODE = 32
