# This file is a part of Julia. License is MIT: https://julialang.org/license

using Test
using REPL
using Dates

using REPL.History
using REPL.History: HistoryFile, HistEntry, update!,
    ConditionSet, FilterSpec, filterchunkrev!, ismorestrict, matchregions,
    SelectorState, componentrows, countlines_selected, hoveridx, ishover, gethover,
    candidates, movehover, toggleselection, fullselection, addcache!

const HISTORY_SAMPLE_FORMAT_1 = """
# time: 2020-10-31 05:16:39 AWST
# mode: julia
\tcos
# time: 2020-10-31 05:16:40 AWST
# mode: help
\tcos
# time: 2021-03-12 09:03:06 AWST
# mode: julia
\tfunction is_leap_year(year)
\t    if year % 4 == 0 && (! year % 100 == 0 || year % 400 == 0)
\t        return true
\t    else
\t        return false
\t    end
\tend
# time: 2021-03-23 16:48:55 AWST
# mode: julia
\tL²norm(x -> x^2, ℐ)
# time: 2021-03-23 16:49:06 AWST
# mode: julia
\tL²norm(x -> 9x, ℐ)
"""

const HISTORY_SAMPLE_FORMAT_2 = """
# time: 2025-10-18 18:21:03Z
# mode: julia
\tIterators.partition([1,2,3,4,5,6,7], 2) |> eltype
# time: 2025-10-19 06:27:10Z
# mode: julia
\tusing Chairmarks
# time: 2025-10-19 06:27:18Z
# mode: julia
\t@b REPL.History.HistoryFile("/home/tec/.julia/logs/repl_history.jl") REPL.History.update!
"""

const HISTORY_SAMPLE_MALFORMED = """
time: 2025-10-18 18:20:59Z
mode: julia
"""

const HISTORY_SAMPLE_BAD_SPACES = """
# time: 2025-10-18 18:20:59Z
# mode: julia
    "Spaces instead of tabs :("
"""

const HISTORY_SAMPLE_INCOMPLETE = """
# time: 2025-05-10 12:34:56Z
# mode: julia
\tfoo()
# time: 2025-05-10 12:40:00Z
# mode: julia
"""

@testset "Histfile" begin
    hpath = tempname()
    mkpath(dirname(hpath))
    @testset "History reading" begin
        @testset "Create empty HistoryFile" begin
            hist = HistoryFile(hpath)
            @test isempty(hist)
            @test length(hist) == 0
            close(hist)
            @test read(hpath, String) == ""
        end
        @testset "Format 1" begin
            write(hpath, HISTORY_SAMPLE_FORMAT_1)
            hist = HistoryFile(hpath)
            update!(hist)
            @test length(hist) == 5
            @test hist[1] == HistEntry(:julia, DateTime("2020-10-31T05:16:39"), "cos", 1)
            @test hist[2] == HistEntry(:help, DateTime("2020-10-31T05:16:40"), "cos", 2)
            funccontent = """
        function is_leap_year(year)
            if year % 4 == 0 && (! year % 100 == 0 || year % 400 == 0)
                return true
            else
                return false
            end
        end"""
            @test hist[3] == HistEntry(:julia, DateTime("2021-03-12T09:03:06"), funccontent, 3)
            @test hist[4] == HistEntry(:julia, DateTime("2021-03-23T16:48:55"), "L²norm(x -> x^2, ℐ)", 4)
            @test hist[5] == HistEntry(:julia, DateTime("2021-03-23T16:49:06"), "L²norm(x -> 9x, ℐ)", 5)
            close(hist)
        end
        @testset "Format 2" begin
            write(hpath, HISTORY_SAMPLE_FORMAT_2)
            hist = HistoryFile(hpath)
            update!(hist)
            @test length(hist) == 3
            @test hist[1] == HistEntry(:julia, DateTime("2025-10-18T18:21:03"), "Iterators.partition([1,2,3,4,5,6,7], 2) |> eltype", 1)
            @test hist[2] == HistEntry(:julia, DateTime("2025-10-19T06:27:10"), "using Chairmarks", 2)
            @test hist[3] == HistEntry(:julia, DateTime("2025-10-19T06:27:18"), "@b REPL.History.HistoryFile(\"/home/tec/.julia/logs/repl_history.jl\") REPL.History.update!", 3)
            close(hist)
        end
        @testset "Malformed" begin
            write(hpath, HISTORY_SAMPLE_MALFORMED)
            hist = HistoryFile(hpath)
            @test_warn "Malformed history entry" update!(hist)
            @test length(hist) == 0
            close(hist)
        end
        @testset "Spaces instead of tabs" begin
            write(hpath, HISTORY_SAMPLE_BAD_SPACES)
            hist = HistoryFile(hpath)
            @test_warn "Malformed history content" update!(hist)
            @test length(hist) == 0
            close(hist)
        end
        @testset "Incomplete entry" begin
            write(hpath, HISTORY_SAMPLE_INCOMPLETE)
            hist = HistoryFile(hpath)
            @test_nowarn update!(hist)
            @test length(hist) == 1
            @test hist[1] == HistEntry(:julia, DateTime("2025-05-10T12:34:56"), "foo()", 1)
            close(hist)
        end
    end

    @testset "History round trip" begin
        write(hpath, "")
        hist = HistoryFile(hpath)
        entries = [
            HistEntry(:julia, DateTime("2024-06-01T10:00:00"), "println(\"Hello, World!\")", 0),
            HistEntry(:shell, DateTime("2024-06-01T10:05:00"), "ls -la", 0),
            HistEntry(:help, DateTime("2024-06-01T10:10:00"), "? println", 0),
        ]
        for entry in entries
            push!(hist, entry)
        end
        close(hist)
        hist = HistoryFile(hpath)
        update!(hist)
        @test length(hist) == length(entries)
        for (i, entry) in enumerate(entries)
            @test hist[i].mode == entry.mode
            @test hist[i].date == entry.date
            @test hist[i].content == entry.content
            @test hist[i].index == i
        end
        close(hist)
    end

    @testset "Incremental updating" begin
        write(hpath, HISTORY_SAMPLE_FORMAT_1)
        hist_a = HistoryFile(hpath)
        hist_b = HistoryFile(hpath)
        update!(hist_a)
        update!(hist_b)
        @test length(hist_b) == 5
        push!(hist_a, HistEntry(:julia, now(UTC), "2 + 2", 0))
        @test length(hist_a) == 6
        update!(hist_b)
        @test length(hist_b) == 6
        @test hist_b[end] == hist_a[end]
        push!(hist_b, HistEntry(:shell, now(UTC), "echo 'Hello'", 0))
        @test length(hist_b) == 7
        update!(hist_a)
        @test length(hist_a) == 7
        @test hist_a[end] == hist_b[end]
        close(hist_a)
        close(hist_b)
    end
end

@testset "Filtering" begin
    @testset "ConditionSet" begin
        @testset "Parsing" begin
            @testset "Basic" begin
                cset = ConditionSet("hello world")
                @test cset.words == [SubString("hello world")]
                @test isempty(cset.exacts)
                @test isempty(cset.negatives)
                @test isempty(cset.initialisms)
                @test isempty(cset.fuzzy)
                @test isempty(cset.regexps)
                @test isempty(cset.modes)
            end
            @testset "Exact match" begin
                cset = ConditionSet("=exact")
                @test cset.exacts == [SubString("exact")]
            end
            @testset "Negative match" begin
                cset = ConditionSet("!exclude")
                @test cset.negatives == [SubString("exclude")]
            end
            @testset "Initialism" begin
                cset = ConditionSet("`im")
                @test cset.initialisms == [SubString("im")]
            end
            @testset "Regexp" begin
                cset = ConditionSet("/foo.*bar")
                @test cset.regexps == [SubString("foo.*bar")]
            end
            @testset "Mode" begin
                cset = ConditionSet("shell>")
                @test cset.modes == [SubString("shell")]
            end
            @testset "Fuzzy" begin
                cset = ConditionSet("~fuzzy")
                @test cset.fuzzy == [SubString("fuzzy")]
            end
            @testset "Space trimming" begin
                cset = ConditionSet("  word with spaces  ")
                @test cset.words == [SubString("word with spaces")]
            end
            @testset "Escaped prefix" begin
                cset = ConditionSet("\\=not exact")
                @test cset.words == [SubString("=not exact")]
            end
            @testset "Multiple conditions" begin
                cset = ConditionSet("word;=exact;!neg")
                @test cset.words == [SubString("word")]
                @test cset.exacts == [SubString("exact")]
                @test cset.negatives == [SubString("neg")]
            end
            @testset "Escaped separator" begin
                cset = ConditionSet("hello\\;world;=exact")
                @test cset.words == [SubString("hello;world")]
                @test cset.exacts == [SubString("exact")]
                cset = ConditionSet("1 \\; 2")
                @test cset.words == [SubString("1 ; 2")]
            end
            @testset "Complex query" begin
                cset = ConditionSet("some = words ;; !error ; julia> ;/^def.*;")
                @test cset.words == [SubString("some = words")]
                @test cset.negatives == [SubString("error")]
                @test cset.modes == [SubString("julia")]
                @test cset.regexps == [SubString("^def.*")]
            end
        end
    end
    @testset "FilterSpec" begin
        @testset "Construction" begin
            @testset "Words" begin
                cset = ConditionSet("bag of words")
                spec = FilterSpec(cset)
                @test isempty(spec.exacts)
                @test spec.regexps == [r"\Qbag\E"i, r"\Qof\E"i, r"\Qwords\E"i]
                cset2 = ConditionSet("Bag of Words")
                spec2 = FilterSpec(cset2)
                @test spec2.exacts == ["Bag", "of", "Words"]
                @test isempty(spec2.regexps)
            end
            @testset "Complex query" begin
                cset = ConditionSet("=exact;!neg;/foo.*bar;julia>")
                spec = FilterSpec(cset)
                @test spec.exacts == ["exact"]
                @test spec.negatives == ["neg"]
                @test spec.regexps == [r"foo.*bar"]
                @test spec.modes == [:julia]
            end
        end
        @testset "Matching" begin
            entries = [
                HistEntry(:julia, now(UTC), "println(\"hello world\")", 1),
                HistEntry(:julia, now(UTC), "log2(1234.5)", 1),
                HistEntry(:julia, now(UTC), "test case", 1),
                HistEntry(:help, now(UTC), "cos", 1),
                HistEntry(:julia, now(UTC), "cos(2π)", 1),
                HistEntry(:julia, now(UTC), "case of tests", 1),
                HistEntry(:shell, now(UTC), "echo 'Hello World'", 4),
                HistEntry(:julia, now(UTC), "foo_bar(2, 7)", 5),
                HistEntry(:julia, now(UTC), "test_fun()", 5),
            ]
            results = HistEntry[]
            @testset "Words" begin
                empty!(results)
                cset = ConditionSet("hello")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen) == 0
                @test results == [entries[1], entries[7]]
                empty!(results)
                cset2 = ConditionSet("world")
                spec2 = FilterSpec(cset2)
                empty!(seen)
                @test filterchunkrev!(results, entries, spec2, seen) == 0
                @test results == [entries[1], entries[7]]
                empty!(results)
                cset3 = ConditionSet("World")
                spec3 = FilterSpec(cset3)
                empty!(seen)
                @test filterchunkrev!(results, entries, spec3, seen) == 0
                @test results == [entries[7]]
            end
            @testset "Exact" begin
                empty!(results)
                cset = ConditionSet("=test")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen; maxresults = 2) == 5
                @test results == [entries[6], entries[9]]
                empty!(results)
                cset2 = ConditionSet("=test case")
                spec2 = FilterSpec(cset2)
                empty!(seen)
                @test filterchunkrev!(results, entries, spec2, seen) == 0
                @test results == [entries[3]]
            end
            @testset "Negative" begin
                empty!(results)
                cset = ConditionSet("!hello ; !test;! cos")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen) == 0
                @test results == [entries[2], entries[7], entries[8]]
            end
            @testset "Initialism" begin
                empty!(results)
                cset = ConditionSet("`tc")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen) == 0
                @test results == [entries[3]]
                empty!(results)
                cset2 = ConditionSet("`fb")
                spec2 = FilterSpec(cset2)
                empty!(seen)
                @test filterchunkrev!(results, entries, spec2, seen) == 0
                @test results == [entries[8]]
            end
            @testset "Regexp" begin
                empty!(results)
                cset = ConditionSet("/^c.s\\b")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen) == 0
                @test results == [entries[4], entries[5]]
            end
            @testset "Mode" begin
                empty!(results)
                cset = ConditionSet("shell>")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen) == 0
                @test results == [entries[7]]
            end
            @testset "Fuzzy" begin
                empty!(results)
                cset = ConditionSet("~cs")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, entries, spec, seen) == 0
                @test results == entries[3:6]
            end
            @testset "Uniqueness" begin
                empty!(results)
                # Create entries with duplicate content in the same mode
                dup_entries = [
                    HistEntry(:julia, now(UTC), "println(\"hello\")", 1),
                    HistEntry(:julia, now(UTC), "cos(2π)", 2),
                    HistEntry(:julia, now(UTC), "println(\"hello\")", 3),  # duplicate
                    HistEntry(:julia, now(UTC), "sin(π)", 4),
                    HistEntry(:julia, now(UTC), "cos(2π)", 5),  # duplicate
                    HistEntry(:julia, now(UTC), "println(\"hello\")", 6),  # duplicate
                    HistEntry(:julia, now(UTC), "tan(π/4)", 7),
                ]
                # When filtering with seen Set, duplicates are removed
                cset = ConditionSet("cos")
                spec = FilterSpec(cset)
                seen = Set{Tuple{Symbol,String}}()
                @test filterchunkrev!(results, dup_entries, spec, seen) == 0
                # Should only get unique entries matching the filter
                # Since we iterate in reverse (7->1), we keep the most recent occurrence of each unique content
                @test length(results) == 1
                @test results[1] == dup_entries[5]  # cos(2π) - most recent
                # When browsing without filtering, duplicates are kept
                empty!(results)
                append!(results, dup_entries)
                @test length(results) == 7  # All entries, including duplicates
                @test results == dup_entries
                # Test that same content in different modes is NOT deduplicated
                empty!(results)
                mode_entries = [
                    HistEntry(:julia, now(UTC), "ls", 1),
                    HistEntry(:shell, now(UTC), "ls", 2),
                    HistEntry(:julia, now(UTC), "ls", 3),  # duplicate in :julia mode
                    HistEntry(:shell, now(UTC), "pwd", 4),
                ]
                empty!(seen)
                cset3 = ConditionSet("ls")
                spec3 = FilterSpec(cset3)
                @test filterchunkrev!(results, mode_entries, spec3, seen) == 0
                @test length(results) == 2  # "ls" from :julia and "ls" from :shell
                @test results[1] == mode_entries[2]  # :shell ls
                @test results[2] == mode_entries[3]  # :julia ls (most recent)
            end
        end
        @testset "matchregions with multibyte characters" begin
            # Handle search for multi-byte characters (issue 61653)
            spec_ab = FilterSpec(ConditionSet("=αβ"))
            @test matchregions(spec_ab, "αβ") == [1:3]
            # Two exact matches separated by a single space should merge, even when
            # the boundary characters are multibyte
            spec_two = FilterSpec(ConditionSet("=αβ;=γδ"))
            @test matchregions(spec_two, "αβ γδ") == [1:8]
            # Adjacent multibyte matches (no space) must not merge
            @test matchregions(spec_two, "αβγδ") == [1:3, 5:7]
            # Multi-character gap must not merge
            @test matchregions(spec_two, "αβ  γδ") == [1:3, 7:9]
            # ASCII sanity: single-space-separated matches still merge
            spec_ascii = FilterSpec(ConditionSet("=foo;=bar"))
            @test matchregions(spec_ascii, "foo bar") == [1:7]
        end
        @testset "matchregions at end of string" begin
            # Duplicate terms matching at the end of the string (issue 62341)
            spec_dup = FilterSpec(ConditionSet("test\\; test\\;"))
            @test matchregions(spec_dup, "test test;") == [6:10, 6:10]
            # Overlapping matches extending to the end of the string
            spec_overlap = FilterSpec(ConditionSet("=st;=test"))
            @test matchregions(spec_overlap, "test") == [1:4, 3:4]
        end
        @testset "Strictness comparison" begin
            c1 = ConditionSet("hello world")
            c2 = ConditionSet("hello world more")
            c3 = ConditionSet("hello world more;!exclude")
            @test ismorestrict(c2, c1)
            @test !ismorestrict(c1, c2)
            @test ismorestrict(c3, c2)
            @test !ismorestrict(c2, c3)
            @test ismorestrict(c3, c1)
            @test !ismorestrict(c1, c3)
        end
    end
end

@testset "Display calculations" begin
    entries = [HistEntry(:julia, now(UTC), "test_$i", i) for i in 1:20]
    @testset "componentrows" begin
        @testset "Standard terminal" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            @test componentrows(state) == (candidates = 13, preview = 6)
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1, 3], gathered = HistEntry[]), 1)
            @test componentrows(state) == (candidates = 13, preview = 6)
            gathered = [HistEntry(:julia, now(UTC), "old", i) for i in 21:22]
            state = SelectorState((30, 80), "", FilterSpec(), entries, gathered)
            @test componentrows(state) == (candidates = 13, preview = 6)
        end
        @testset "Terminal size variations" begin
            @test componentrows(SelectorState((10, 80), "", FilterSpec(), entries)) == (candidates = 6, preview = 0)
            @test componentrows(SelectorState((5, 40), "", FilterSpec(), entries)) == (candidates = 2, preview = 0)
            @test componentrows(SelectorState((1, 80), "", FilterSpec(), entries)) == (candidates = 0, preview = 0)
            @test componentrows(SelectorState((100, 200), "", FilterSpec(), entries)) == (candidates = 44, preview = 22)
        end
        @testset "Preview clamping" begin
            multiline = join(["line$i" for i in 1:20], '\n')
            state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), multiline, 1)], 0, (active = [1], gathered = HistEntry[]), 1)
            @test componentrows(state) == (candidates = 7, preview = 12)
        end
    end
    @testset "countlines_selected" begin
        @testset "Basic counting" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            @test countlines_selected(state) == 0
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1], gathered = HistEntry[]), 1)
            @test countlines_selected(state) == 1
        end
        @testset "Multi-line entries" begin
            code = "begin\n    x = 10\n    y = 20\n    x + y\nend"
            state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), code, 1)], 0, (active = [1], gathered = HistEntry[]), 1)
            @test countlines_selected(state) == 5
            huge = join(["line" for _ in 1:1000], '\n')
            state = SelectorState((30, 80), "", FilterSpec(), [HistEntry(:julia, now(UTC), huge, 1)], 0, (active = [1], gathered = HistEntry[]), 1)
            @test countlines_selected(state) == 1000
        end
        @testset "With gathered entries" begin
            gathered = [HistEntry(:julia, now(UTC), "old", i) for i in 21:22]
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1], gathered), 1)
            @test countlines_selected(state) == 4
        end
    end
    @testset "gethover" begin
        @testset "Basic retrieval" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            @test gethover(state) == entries[20]
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 3)
            @test gethover(state) == entries[18]
        end
        @testset "With gathered entries" begin
            gathered = [HistEntry(:julia, now(UTC), "old_$i", i) for i in 21:22]
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered), -2)
            @test gethover(state) == gathered[2]
        end
        @testset "Invalid hover positions" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 0)
            @test gethover(state) === nothing
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 999)
            @test gethover(state) === nothing
        end
    end
    @testset "candidates" begin
        @testset "Basic windowing" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            cands = candidates(state, 10)
            @test cands.active.rows == 10
            @test cands.active.width == 80
            @test cands.active.entries == entries[11:20]
            @test cands.active.selected == Int[]
            @test cands.gathered.rows == 0
        end
        @testset "With selections" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [5, 15, 18], gathered = HistEntry[]), 1)
            cands = candidates(state, 10)
            @test cands.active.selected == [-5, 5, 8]
        end
        @testset "With gathered entries" begin
            gathered = [HistEntry(:julia, now(UTC), "gathered_$i", 20+i) for i in 1:2]
            state = SelectorState((30, 80), "", FilterSpec(), entries, gathered)
            state = SelectorState(state.area, state.query, state.filter, state.candidates, -2, state.selection, 1)
            cands = candidates(state, 10)
            @test cands.gathered.rows == 2
            @test cands.gathered.entries == gathered
            @test cands.gathered.selected == [1, 2]
        end
        @testset "Scrolling" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 6)
            state = SelectorState(state.area, state.query, state.filter, state.candidates, 5, state.selection, 6)
            cands = candidates(state, 10)
            @test cands.active.entries[1] == entries[6]
            @test cands.active.entries[end] == entries[15]
        end
        @testset "Edge cases" begin
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[])
            cands = candidates(state, 10)
            @test isempty(cands.active.entries)
            @test cands.active.rows == 10
            gathered = [HistEntry(:julia, now(UTC), "old_$i", 20+i) for i in 1:15]
            state = SelectorState((30, 80), "", FilterSpec(), entries, gathered)
            state = SelectorState(state.area, state.query, state.filter, state.candidates, -10, state.selection, -1)
            cands = candidates(state, 8)
            @test cands.gathered.rows == 7
            @test cands.active.rows == 0
            few = [HistEntry(:julia, now(UTC), "entry_$i", i) for i in 1:3]
            state = SelectorState((30, 80), "", FilterSpec(), few)
            cands = candidates(state, 20)
            @test cands.active.entries == few
        end
    end
end

@testset "Search state manipulation" begin
    entries = [HistEntry(:julia, now(UTC), "test_$i", i) for i in 1:20]
    @testset "movehover" begin
        @testset "Single step moves" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 5)
            @test movehover(state, false, false).hover == 4
            @test movehover(state, true, false).hover == 6
        end
        @testset "Page moves" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 5)
            @test movehover(state, false, true).hover == 1
            @test movehover(state, true, true).hover == 17
        end
        @testset "Boundary clamping" begin
            top = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 20)
            @test movehover(top, true, false).hover == 20
            bottom = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 1)
            @test movehover(bottom, false, false).hover == 1
        end
        @testset "With gathered entries" begin
            gathered = [HistEntry(:julia, now(UTC), "old_cmd", 21)]
            state = SelectorState((30, 80), "", FilterSpec(), entries, gathered)
            state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, 1)
            @test movehover(state, false, false).hover == -1
            state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, 1)
            down = movehover(state, false, false)
            @test down.hover == -1
            up = movehover(down, true, false)
            @test up.hover == 1
        end
        @testset "Empty candidates" begin
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[])
            @test movehover(state, true, false).hover == 1
            @test movehover(state, false, false).hover == 1
            gathered = [HistEntry(:julia, now(UTC), "old_cmd", 1)]
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], gathered)
            state = SelectorState(state.area, state.query, state.filter, state.candidates, -1, state.selection, -1)
            @test movehover(state, true, false).hover == 1
            @test movehover(state, false, false).hover == -1
        end
        @testset "Single candidate" begin
            one = [HistEntry(:julia, now(UTC), "only", 1)]
            state = SelectorState((30, 80), "", FilterSpec(), one)
            @test movehover(state, true, false).hover == 1
            @test movehover(state, false, false).hover == 1
        end
    end
    @testset "toggleselection" begin
        @testset "Basic toggle" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            state = toggleselection(state)
            @test state.selection.active == [20]
            state = toggleselection(state)
            @test state.selection.active == Int[]
        end
        @testset "Multiple selections" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            state = toggleselection(state)
            state = movehover(state, true, false)
            state = movehover(state, true, false)
            state = toggleselection(state)
            @test state.selection.active == [18, 20]
        end
        @testset "Gathered entries" begin
            gathered = [HistEntry(:julia, now(UTC), "old_$i", 20+i) for i in 1:2]
            state = SelectorState((30, 80), "", FilterSpec(), entries, -1, (active = Int[], gathered), -1)
            @test toggleselection(state).selection.gathered == [gathered[2]]
        end
        @testset "Edge cases" begin
            invalid = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 0)
            @test toggleselection(invalid) === invalid
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], gathered = HistEntry[]), 1)
            result = toggleselection(state)
            @test 20 ∉ result.selection.active
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[])
            @test toggleselection(state) === state
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 100)
            @test toggleselection(state) === state
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 20)
            @test 1 in toggleselection(state).selection.active
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 1)
            @test 20 in toggleselection(state).selection.active
        end
    end
    @testset "fullselection" begin
        entries = [
            HistEntry(:julia, now(UTC), "using DataFrames", 1),
            HistEntry(:julia, now(UTC), "df = load_data()", 2),
            HistEntry(:shell, now(UTC), "cat data.csv", 3),
            HistEntry(:julia, now(UTC), "describe(df)", 4),
        ]
        @testset "No selection" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries)
            @test fullselection(state) == (mode = :julia, text = "describe(df)")
        end
        @testset "Single selection" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [2], gathered = HistEntry[]), 1)
            @test fullselection(state) == (mode = :julia, text = "df = load_data()")
        end
        @testset "Multiple selections" begin
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [4, 1, 3], gathered = HistEntry[]), 1)
            @test fullselection(state) == (mode = :julia, text = "using DataFrames\ncat data.csv\ndescribe(df)")
        end
        @testset "With gathered entries" begin
            gathered = [HistEntry(:julia, now(UTC), "ENV[\"COLUMNS\"] = 120", 0)]
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = [2], gathered), 1)
            @test fullselection(state) == (mode = :julia, text = "ENV[\"COLUMNS\"] = 120\ndf = load_data()")
        end
        @testset "Edge cases" begin
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered = HistEntry[]), 1)
            @test fullselection(state) == (mode = nothing, text = "")
            state = SelectorState((30, 80), "", FilterSpec(), entries, 0, (active = Int[], gathered = HistEntry[]), 100)
            @test fullselection(state) == (mode = nothing, text = "")
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered = HistEntry[]), -1)
            @test fullselection(state) == (mode = nothing, text = "")
            gathered = [HistEntry(:julia, now(UTC), "old_1", 1)]
            state = SelectorState((30, 80), "", FilterSpec(), HistEntry[], 0, (active = Int[], gathered), -1)
            @test fullselection(state) == (mode = :julia, text = "old_1")
        end
    end
    @testset "addcache!" begin
        cache, state = Int[], zero(UInt8)
        for i in 1:128
            state = addcache!(cache, state, i)
        end
        @test cache == [1, 65, 97, 113, 121, 125, 127, 128]
    end
end

# TODO: Prompt handling/events, terminal rendering, and end-to-end integration tests
