-- Copyright (C) 2019 the mpv developers
--
-- Permission to use, copy, modify, and/or distribute this software for any
-- purpose with or without fee is hereby granted, provided that the above
-- copyright notice and this permission notice appear in all copies.
--
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

local msg = require "mp.msg"
local utils = require "mp.utils"
local assdraw = require "mp.assdraw"

local function detect_platform()
    local platform = mp.get_property_native("platform")
    if platform == "darwin" or platform == "windows" then
        return platform
    elseif os.getenv("WAYLAND_DISPLAY") or os.getenv("WAYLAND_SOCKET") then
        return "wayland"
    end
    return "x11"
end

local platform = detect_platform()

-- Default options
local script_opts = {
    monospace_font = "",
    font_size = 24,
    border_size = 1.65,
    background_alpha = 80,
    gap = 0.2,
    padding = 10,
    menu_outline_size = 0,
    menu_outline_color = "#FFFFFF",
    corner_radius = 8,
    margin_x = -1,
    margin_y = -1,
    scale_with_window = "auto",
    focused_color = "#222222",
    focused_back_color = "#FFFFFF",
    match_color = "#0088FF",
    exact_match = false,
    case_sensitive = false,
    history_dedup = true,
    font_hw_ratio = "auto",
}
local opts = script_opts

local styles = {
    error = "{\\1c&H7a77f2&}",
    completion = "{\\1c&Hcc99cc&}",
    inactive = "{\\alpha&H66&}",
}
for key, style in pairs(styles) do
    styles[key] = style .. "{\\3c&H111111&}"
end

local terminal_styles = {
    error = "\027[31m",
    selected_completion = "\027[7m",
    default_item = "\027[1m",
    disabled = "\027[38;5;8m",
    matched_position = "\027[34m",
    match_end = "\027[39m",
}

local open = false
local osd_msg_active = false
local insert_mode = false
local pending_update = false
local ime_active = false
local line = ""
local cursor = 1
local prompt
local id

local overlay = mp.create_osd_overlay("ass-events")
local width_overlay = mp.create_osd_overlay("ass-events")
width_overlay.compute_bounds = true
width_overlay.hidden = true

local histories = {}
local history = {}
local history_pos = 1
local searching_history = false
local history_paths = {}
local histories_to_save = {}

local MAX_LOG_LINES = 10000
local log_buffers = {}
local log_offset = 0
local key_bindings = {}
local global_margins = { t = 0, b = 0 }
local input_caller
local input_caller_handler
local keep_open = false

local completion_buffer = {}
local dim_completions = false
local selected_completion_index
local completion_pos
local completion_append
local autoselect_completion
local has_completions

local selectable_items
local selectable_items_lower
local matches = {}
local matches_query = { needle = "" }
local focused_match = 1
local first_match_to_print = 1
local default_item
local item_positions = {}
local max_item_width = 0
local horizontal_offset = 0

local complete
local cycle_through_completions
local set_active
local render

local property_cache = {}

local completion_timer = mp.add_timeout(0.1, function()
    dim_completions = true
    render()
end, true)


local function get_property_cached(name, def)
    if property_cache[name] ~= nil then
        return property_cache[name]
    end
    return def
end

local function get_font()
    if not has_completions then
        return
    end

    if opts.monospace_font ~= "" then
        return opts.monospace_font
    end

    -- Pick a better default font for Windows and macOS
    if platform == "windows" then
        return "Consolas"
    end

    if platform == "darwin" then
        return "Menlo"
    end

    return "monospace"
end

local function get_margin_x()
    return opts.margin_x > -1 and opts.margin_x or mp.get_property_native("osd-margin-x")
end

local function get_margin_y()
    return opts.margin_y > -1 and opts.margin_y or mp.get_property_native("osd-margin-y")
end


-- Naive helper function to find the next UTF-8 character in "str" after "pos"
-- by skipping continuation bytes. Assumes "str" contains valid UTF-8.
local function next_utf8(str, pos)
    if pos > str:len() then return pos end
    repeat
        pos = pos + 1
    until pos > str:len() or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf
    return pos
end

-- As above, but finds the previous UTF-8 character in "str" before "pos"
local function prev_utf8(str, pos)
    if pos <= 1 then return pos end
    repeat
        pos = pos - 1
    until pos <= 1 or str:byte(pos) < 0x80 or str:byte(pos) > 0xbf
    return pos
end

local function len_utf8(str)
    local len = 0
    local pos = 1
    while pos <= str:len() do
        pos = next_utf8(str, pos)
        len = len + 1
    end
    return len
end

local function utf8_positions(str)
    local pos = 1
    local positions = {true}

    while pos <= str:len() do
        pos = next_utf8(str, pos)
        positions[pos] = true
    end

    return positions
end

-- Converts values to strings, using JSON formatting for tables.
-- JSON is used instead of utils.to_string as that is the format the
-- values are received in from script-messages.
local function to_string(v)
    if type(v) == "table" then
        return utils.format_json(v)
    else
        return tostring(v)
    end
end


-- Functions to calculate the font width.
local width_length_ratio = 0.5
local osd_width, osd_height = 0, 0
local text_osd = mp.create_osd_overlay("ass-events")
text_osd.compute_bounds, text_osd.hidden = true, true

local function measure_bounds(ass_text)
    text_osd.res_x, text_osd.res_y = osd_width, osd_height
    text_osd.data = ass_text
    local res = text_osd:update()
    return res.x0, res.y0, res.x1, res.y1
end

---Measure text width and normalize to a font size of 1
---text has to be ass safe
local function normalized_text_width(text, size, horizontal)
    local align, rotation = horizontal and 7 or 1, horizontal and 0 or -90
    local template = "{\\pos(0,0)\\rDefault\\blur0\\bord0\\shad0\\q2\\an%s\\fs%s\\fn%s\\frz%s}%s"
    size = size / 0.8
    local width
    -- Limit to 5 iterations
    local repetitions_left = 5
    for i = 1, repetitions_left do
        size = size * 0.8
        local ass = assdraw.ass_new()
        ass.text = template:format(align, size, get_font(), rotation, text)
        local _, _, x1, y1 = measure_bounds(ass.text)
        -- Check if nothing got clipped
        if x1 and x1 < osd_width and y1 < osd_height then
            width = horizontal and x1 or y1
            break
        end
        if i == repetitions_left then
            width = 0
        end
    end
    return width / size, horizontal and osd_width or osd_height
end

local function fit_on_osd(text)
    local estimated_width = #text * width_length_ratio
    if osd_width >= osd_height then
        -- Fill the osd as much as possible, bigger is more accurate.
        return math.min(osd_width / estimated_width, osd_height), true
    else
        return math.min(osd_height / estimated_width, osd_width), false
    end
end

local measured_font_hw_ratio = nil
local function get_font_hw_ratio()
    local font_hw_ratio = tonumber(opts.font_hw_ratio)
    if font_hw_ratio then
        return font_hw_ratio
    end
    if not measured_font_hw_ratio then
        local alphabet = "abcdefghijklmnopqrstuvwxyz"
        local text = alphabet:rep(3)
        local size, horizontal = fit_on_osd(text)
        local normalized_width = normalized_text_width(text, size * 0.9, horizontal)
        measured_font_hw_ratio = #text / normalized_width * 0.95
    end
    return measured_font_hw_ratio
end


-- Escape a string for verbatim display on the OSD
local function ass_escape(str)
    return mp.command_native({"escape-ass", str})
end

local function should_scale()
    return opts.scale_with_window == "yes" or
           (opts.scale_with_window == "auto" and mp.get_property_native("osd-scale-by-window"))
end

local function scale_factor()
    if should_scale() and osd_height > 0 then
        return osd_height / 720
    end

    return get_property_cached("display-hidpi-scale", 1)
end

local function terminal_output()
    -- Unlike vo-configured, current-vo doesn't become falsy while switching VO,
    -- which would print the log to the OSD.
    return not mp.get_property("current-vo") or not mp.get_property_native("video-osd")
end

local function get_scaled_osd_dimensions()
    local scale = scale_factor()

    return osd_width / scale, osd_height / scale
end

local function get_line_height()
    if selectable_items then
        return opts.font_size * (1 + opts.gap)
    end

    return opts.font_size
end

local function calculate_max_lines()
    if terminal_output() then
        -- Subtract 1 for the input line and for each line in the status line.
        -- This does not detect wrapped lines.
        return mp.get_property_native("term-size/h", 24) - 2 -
               select(2, mp.get_property("term-status-msg"):gsub("\\n", ""))
    end

    return math.floor((select(2, get_scaled_osd_dimensions())
                       * (1 - global_margins.t - global_margins.b)
                       - get_margin_y() - (selectable_items and opts.padding * 2 or 0))
                      / get_line_height()
                      -- Subtract 1 for the input line and 0.5 for the empty
                      -- line between the log and the input line.
                      - 1.5)
end

local function calculate_max_item_width()
    if not selectable_items or terminal_output() then
        return
    end

    local longest_item = prompt .. ("a"):rep(9)
    if #selectable_items > calculate_max_lines() then
        local digits = math.floor(math.log(#selectable_items, 10)) + 1
        longest_item = longest_item .. ("0"):rep(digits) .. "/" .. ("0"):rep(digits)
    end
    local longest_item_width = utils.terminal_display_width(longest_item)
    for _, item in pairs(selectable_items) do
        local item_width = utils.terminal_display_width(item)
        if item_width > longest_item_width then
            longest_item = item
            longest_item_width = item_width
        end
    end

    local osd_w, osd_h = get_scaled_osd_dimensions()
    local font = get_font()
    width_overlay.res_x = osd_w
    width_overlay.res_y = osd_h
    width_overlay.data = "{\\fs" .. opts.font_size ..
                         (font and "\\fn" .. font or "") .. "\\q2}" ..
                         ass_escape(longest_item)
    local result = width_overlay:update()
    if result.x0 then
        max_item_width = math.min(result.x1 - result.x0,
                                  osd_w - get_margin_x() * 2 - opts.padding * 2)
    end
end

local function should_highlight_completion(i)
    return i == selected_completion_index or
           (i == 1 and selected_completion_index == 0 and autoselect_completion)
end

local function mpv_color_to_ass(color)
    return color:sub(8,9) .. color:sub(6,7) ..  color:sub(4,5),
           string.format("%x", 255 - tonumber("0x" .. color:sub(2,3)))
end

local function color_option_to_ass(color)
    return color:sub(6,7) .. color:sub(4,5) ..  color:sub(2,3)
end

local function get_selected_ass()
    local color, alpha = mpv_color_to_ass(mp.get_property("osd-selected-color"))
    local outline_color, outline_alpha =
        mpv_color_to_ass(mp.get_property("osd-selected-outline-color"))
    return "{\\b1\\1c&H" .. color .. "&\\1a&H" .. alpha ..
           "&\\3c&H" .. outline_color .. "&\\3a&H" .. outline_alpha .. "&}"
end

-- Takes a list of strings, a max width in characters and
-- optionally a max row count.
-- The result contains at least one column.
-- Rows are cut off from the top if rows_max is specified.
-- returns a string containing the formatted table and the row count
local function format_grid(list, width_max, rows_max)
    if #list == 0 then
        return "", 0
    end

    local spaces_min = 2
    local spaces_max = 8
    local list_size = #list
    local column_count = 1
    local row_count = list_size
    local column_widths
    -- total width without spacing
    local width_total = 0

    local list_widths = {}
    for i, item in ipairs(list) do
        list_widths[i] = len_utf8(item)
    end

    -- use as many columns as possible
    for columns = 2, list_size do
        local rows_lower_bound = math.min(rows_max, math.ceil(list_size / columns))
        local rows_upper_bound = math.min(rows_max, list_size,
                                          math.ceil(list_size / (columns - 1) - 1))
        for rows = rows_upper_bound, rows_lower_bound, -1 do
            local cw = {}
            width_total = 0

            -- find out width of each column
            for column = 1, columns do
                local width = 0
                for row = 1, rows do
                    local i = row + (column - 1) * rows
                    local item_width = list_widths[i]
                    if not item_width then break end
                    if width < item_width then
                        width = item_width
                    end
                end
                cw[column] = width
                width_total = width_total + width
                if width_total + (columns - 1) * spaces_min > width_max then
                    break
                end
            end

            if width_total + (columns - 1) * spaces_min <= width_max then
                row_count = rows
                column_count = columns
                column_widths = cw
            else
                break
            end
        end
        if width_total + (columns - 1) * spaces_min > width_max then
            break
        end
    end

    local spaces = math.floor((width_max - width_total) / (column_count - 1))
    spaces = math.max(spaces_min, math.min(spaces_max, spaces))
    local spacing = column_count > 1
                    and ass_escape(string.format("%" .. spaces .. "s", " "))
                    or ""

    local rows = {}
    for row = 1, row_count do
        local columns = {}
        for column = 1, column_count do
            local i = row + (column - 1) * row_count
            if i > #list then break end
            -- more then 99 leads to "invalid format (width or precision too long)"
            local format_string = column == column_count and "%s"
                                  or "%-" .. math.min(column_widths[column], 99) .. "s"
            columns[column] = ass_escape(string.format(format_string, list[i]))

            if should_highlight_completion(i) then
                local style_override = dim_completions and styles.inactive or ""
                columns[column] = get_selected_ass() .. style_override .. columns[column] ..
                                  "{\\b\\1a&\\3a&}" .. styles.completion .. style_override
            end
        end
        -- first row is at the bottom
        rows[row_count - row + 1] = table.concat(columns, spacing)
    end
    return table.concat(rows, ass_escape("\n")), row_count
end

local fzy = require "mp.fzy"

-- fzy scores are sums of the score constants in fzy.lua, which are all
-- multiples of 0.005, so 200 * score is an integer. This allows packing a
-- match's score and its position in the list into a single number. This makes
-- sort infinitely faster, because it no longer has to call lua comparator.
-- Positions are packed into the low bits, so this supports lists of up to
-- sort_ranks items.
local sort_ranks = 2 ^ 30
local score_infinity = fzy.get_score_ceiling() * 200 + 1

-- Stable sort the indices in `indices` by their `scores`.
local function sort_by_score(indices, scores)
    local keys = {}
    for i = 1, #indices do
        local score = scores[i] * 200
        if score > score_infinity then
            score = score_infinity
        elseif score < -score_infinity then
            score = -score_infinity
        else
            score = math.floor(score + 0.5)
        end
        keys[i] = (i - 1) - score * sort_ranks
    end

    table.sort(keys)

    local sorted = {}
    for i = 1, #keys do
        sorted[i] = indices[keys[i] % sort_ranks + 1]
    end

    return sorted
end

local function fuzzy_find(needle, haystacks)
    local indices = {}
    local scores = {}
    fzy.filter_range(needle, haystacks, 1, #haystacks, indices, scores)

    return sort_by_score(indices, scores)
end

local function get_match_positions(index)
    local needle = matches_query.needle

    if needle == "" then
        return {}
    end

    local text = selectable_items[index]

    if not matches_query.exact then
        return (fzy.positions(needle, text))
    end

    if not matches_query.case_sensitive then
        text = selectable_items_lower[index]
    end

    local positions = {}
    for _, word in ipairs(matches_query.words) do
        local start_pos, end_pos = text:find(word, 1, true)

        if start_pos then
            for i = start_pos, end_pos do
                positions[#positions + 1] = i
            end
        end
    end
    table.sort(positions)

    return positions
end

local function get_matches_to_print(terminal)
    if not selectable_items or focused_match == 0 then
        return {}
    end

    local items = {}
    local max_lines = calculate_max_lines()
    local escape = terminal and function (str) return str end or ass_escape
    local highlight = terminal and terminal_styles.matched_position or
                     "{\\1c&H" .. color_option_to_ass(opts.match_color) .. "}"

    if focused_match < first_match_to_print then
        first_match_to_print = focused_match
    elseif focused_match > first_match_to_print + max_lines - 1 then
        first_match_to_print = focused_match - max_lines + 1
    end

    local last_match_to_print  = math.min(first_match_to_print + max_lines - 1,
                                          #matches)

    if last_match_to_print - first_match_to_print + 1 < math.min(max_lines, #matches) and
       last_match_to_print >= math.min(max_lines, #matches) then
        first_match_to_print = last_match_to_print - math.min(max_lines, #matches) + 1
    end

    for i = first_match_to_print, last_match_to_print do
        local item = ""
        local end_highlight = terminal and terminal_styles.match_end or "{\\1c}"
        local text = selectable_items[matches[i]]
        local positions = get_match_positions(matches[i])

        if terminal then
            if matches[i] == default_item then
                item = terminal_styles.default_item
            end
            if i == focused_match then
                item = item .. terminal_styles.selected_completion
            end
        else
            if i == focused_match then
                if searching_history and
                   mp.get_property("osd-border-style") == "outline-and-shadow" then
                    item = get_selected_ass()
                else
                    item = "{\\1c&H" .. color_option_to_ass(opts.focused_color) .. "&}"
                end
                end_highlight = item
            end
        end

        local char_positions = utf8_positions(text)
        local start_of_last_match = positions[1]

        if not start_of_last_match then
            item = item .. escape(text)
        elseif start_of_last_match > 1 then
            item = item .. escape(text:sub(1, start_of_last_match - 1))
        end

        for j, pos in ipairs(positions) do
            local last_pos = positions[j - 1]

            if last_pos and pos > last_pos + 1 then
                if char_positions[start_of_last_match] and char_positions[last_pos + 1] then
                    item = item .. highlight ..
                           escape(text:sub(start_of_last_match, last_pos)) ..
                           end_highlight ..
                           escape(text:sub(last_pos + 1, pos - 1))
                else
                    item = item .. escape(text:sub(start_of_last_match, pos - 1))
                end

                start_of_last_match = pos
            end
        end

        if start_of_last_match then
            local last_pos = positions[#positions]

            if char_positions[start_of_last_match] and char_positions[last_pos + 1] then
                item = item .. highlight ..
                       escape(text:sub(start_of_last_match, last_pos)) ..
                       end_highlight ..
                       escape(text:sub(last_pos + 1))
            else
                item = item .. escape(text:sub(start_of_last_match))
            end
        end

        items[#items + 1] = item
    end

    return items
end

local function update_overlay(data, res_x, res_y, z)
    if overlay.data == data and
       overlay.res_x == res_x and
       overlay.res_y == res_y and
       overlay.z == z then
        return
    end

    overlay.data = data
    overlay.res_x = res_x
    overlay.res_y = res_y
    overlay.z = z
    overlay:update()
end

local function print_to_terminal()
    -- Clear the log after closing the console.
    if not open then
        if osd_msg_active then
            mp.osd_message("")
        end
        osd_msg_active = false
        return
    end

    local log = ""
    local counter = ""
    if selectable_items then
        if #selectable_items > calculate_max_lines() then
            local digits = math.floor(math.log(#selectable_items, 10)) + 1
            counter = terminal_styles.disabled ..
                      "[" .. string.format("%0" .. digits .. "d", focused_match) ..
                      "/" .. string.format("%0" .. digits .. "d", #matches) ..
                      "]\027[0m "
        end

        local clip = mp.get_property("term-clip-cc")
        for _, item in ipairs(get_matches_to_print(true)) do
            log = log .. clip .. item .. "\027[0m\n"
        end
    else
        for _, log_line in ipairs(log_buffers[id]) do
            log = log .. log_line.terminal_style .. log_line.text .. "\027[0m\n"
        end

        if not dim_completions then
            local term_size = mp.get_property_native("term-size", { w = 80, h = 24 })
            local max_width = term_size.w * (term_size.h - 2 - select(2,
                (prompt .. mp.get_property("term-status-msg")):gsub("\\n", "")))
            local completions = ""

            for i, completion in ipairs(completion_buffer) do
                if should_highlight_completion(i) then
                    completions = completions .. terminal_styles.selected_completion ..
                                  completion .. "\027[0m"
                else
                    completions = completions .. completion
                end
                completions = completions .. (i < #completion_buffer and "\t" or "\n")

                if utils.terminal_display_width(completions) > max_width then
                    completions = completions .. "\n"
                    break
                end
            end

            log = log .. completions
        end
    end

    local before_cur = line:sub(1, cursor - 1)
    local after_cur = line:sub(cursor)
    -- Ensure there is a character with inverted colors to print.
    if after_cur == "" then
        after_cur = " "
    end

    mp.osd_message(log .. counter .. prompt .. " " .. before_cur .. "\027[7m" ..
                   after_cur:sub(1, 1) .. "\027[0m" .. after_cur:sub(2), 999)
    osd_msg_active = true
end

render = function()
    pending_update = false

    if terminal_output() then
        print_to_terminal()
        return
    end

    -- Clear the OSD if the console was being printed to the terminal
    if osd_msg_active then
        mp.osd_message("")
        osd_msg_active = false
    end

    -- Clear the OSD after closing the console
    if not open then
        update_overlay("", 0, 0, 0)
        return
    end

    local ass = assdraw.ass_new()
    local osd_w, osd_h = get_scaled_osd_dimensions()
    local line_height = get_line_height()
    local max_lines = calculate_max_lines()

    local x, y, alignment, clipping_coordinates
    if selectable_items and not searching_history then
        x = (osd_w - max_item_width) / 2
        y = osd_h *
            (global_margins.t + (1 - global_margins.t - global_margins.b) / 2) -
            (math.min(#selectable_items, max_lines) + 1.5) * line_height / 2
        alignment = 7
        clipping_coordinates = x .. ",0," .. x + max_item_width .. "," .. osd_h
    else
        x = get_margin_x()
        y = osd_h * (1 - global_margins.b) - get_margin_y()
        alignment = 1
        -- Avoid drawing below topbar OSC when there are wrapped lines.
        local coordinate_top = math.floor(global_margins.t * osd_h + 0.5)
        clipping_coordinates = "0," .. coordinate_top .. "," .. osd_w .. "," .. osd_h
    end

    local font = get_font()
    -- Use the same blur value as the rest of the OSD. 288 is the OSD's
    -- PlayResY.
    local blur = mp.get_property_native("osd-blur") * osd_h / 288
    local border_style = mp.get_property("osd-border-style")

    local style = "{\\r" ..
                  (font and "\\fn" .. font or "") ..
                  "\\fs" .. opts.font_size ..
                  "\\bord" .. opts.border_size .. "\\fsp0" ..
                  "\\blur" .. blur ..
                  (selectable_items and "\\q2" or "\\q1") ..
                  "\\clip(" .. clipping_coordinates .. ")}"

    -- Create the cursor glyph as an ASS drawing. ASS will draw the cursor
    -- inline with the surrounding text, but it sets the advance to the width
    -- of the drawing. So the cursor doesn't affect layout too much, make it as
    -- thin as possible and make it appear to be 1px wide by giving it 0.5px
    -- horizontal borders.
    local color, alpha = mpv_color_to_ass(mp.get_property("osd-color"))
    local cheight = opts.font_size * 8
    local cglyph = "{\\r\\blur0" ..
                   (get_property_cached("focused") == false
                    and "\\alpha&HFF&" or "\\3a&H" .. alpha .. "&") ..
                   "\\3c&H" .. color .. "&" ..
                   "\\xbord0.5\\ybord0\\xshad0\\yshad1\\p4\\pbo24}" ..
                   "m 0 0 l 1 0 l 1 " .. cheight .. " l 0 " .. cheight ..
                   "{\\p0}"
    local before_cur = ass_escape(line:sub(1, cursor - 1))
    local after_cur = ass_escape(line:sub(cursor))

    local log_ass = ""
    local log_buffer = log_buffers[id] or {}
    log_offset = math.max(math.min(log_offset, #log_buffer - 1), 0)
    local last = #log_buffer - log_offset
    local first = math.max(last - math.min(max_lines, #log_buffer) + 1, 1)
    for i = first, last do
        log_ass = log_ass .. style .. log_buffer[i].style ..
                  ass_escape(log_buffer[i].text) .. "\\N"
    end

    local completion_ass = ""
    if completion_buffer[1] and not selectable_items then
        -- Estimate how many characters fit in one line
        -- Even with bottom-left anchoring,
        -- libass/ass_render.c:ass_render_event() subtracts --osd-margin-x from
        -- the maximum text width twice.
        -- TODO: --osd-margin-x should scale with osd-width and PlayResX to make
        -- the calculation accurate.
        local width_max = math.floor(
            (osd_w - x - mp.get_property_native("osd-margin-x") * 2)
            / opts.font_size * get_font_hw_ratio())

        local completions, rows = format_grid(completion_buffer, width_max, max_lines)
        max_lines = max_lines - rows
        completion_ass = style .. styles.completion ..
                         (dim_completions and styles.inactive or "") ..
                         completions .. "\\N"
    end

    -- Background
    if selectable_items and
       (not searching_history or border_style == "background-box") then
        style = style .. "{\\bord0\\blur0\\4a&Hff&}"
        local back_color, back_alpha = mpv_color_to_ass(mp.get_property(
            border_style == "background-box" and "osd-back-color" or "osd-outline-color"))
        if not searching_history then
            back_alpha = string.format("%x", opts.background_alpha)
        end

        ass:new_event()
        ass:an(alignment)
        ass:pos(x, y)
        ass:append("{\\1c&H" .. back_color .. "&\\1a&H" .. back_alpha ..
                   "&\\bord" .. opts.menu_outline_size .. "\\3c&H" ..
                   color_option_to_ass(opts.menu_outline_color) .. "\\blur0&}")
        if border_style == "background-box" then
            ass:append("{\\4a&Hff&}")
        end
        ass:draw_start()
        ass:round_rect_cw(-opts.padding,
                          opts.padding * (alignment == 7 and -1 or 1),
                          max_item_width + opts.padding,
                          (1.5 + math.min(#matches, max_lines)) * line_height +
                          opts.padding * (alignment == 7 and 1 or 2),
                          opts.corner_radius, opts.corner_radius)
        ass:draw_stop()
    end

    local items = get_matches_to_print()
    item_positions = {}
    for i, item in ipairs(items) do
        local item_y = alignment == 7
            and y + (1 + i) * line_height
            or y - (1.5 + #items - i) * line_height

        if (first_match_to_print - 1 + i == focused_match or
            matches[first_match_to_print - 1 + i] == default_item)
           and (not searching_history or border_style == "background-box") then
            ass:new_event()
            ass:an(4)
            ass:pos(x, item_y)
            ass:append("{\\blur0\\bord0\\4aH&ff&\\1c&H" ..
                       color_option_to_ass(opts.focused_back_color) .. "&}")
            if first_match_to_print - 1 + i ~= focused_match then
                ass:append("{\\1aH&cc&}")
            end
            ass:draw_start()
            ass:rect_cw(-opts.padding, 0, max_item_width + opts.padding, line_height)
            ass:draw_stop()
        end

        ass:new_event()
        ass:an(4)
        ass:pos(x - horizontal_offset, item_y)
        ass:append(style .. item)

        item_positions[#item_positions + 1] =
            { item_y - line_height / 2, item_y + line_height / 2 }
    end

    -- Scrollbar
    if selectable_items and #matches > max_lines then
        ass:new_event()
        ass:an(alignment + 2)
        ass:pos(x + max_item_width, y)
        ass:append(style)
        if not searching_history or border_style == "background-box" then
            ass:append("{\\bord0\\4a&Hff&\\blur0}")
        end
        ass:append(focused_match .. "/" .. #matches)

        local start_percentage = (first_match_to_print - 1) / #matches
        local end_percentage = (first_match_to_print - 1 + max_lines) / #matches
        if end_percentage - start_percentage < 0.04 then
            local diff = 0.04 - (end_percentage - start_percentage)
            start_percentage = start_percentage * (1 - diff)
            end_percentage = end_percentage + diff * (1 - end_percentage)
        end

        local max_height = max_lines * line_height
        local bar_y = alignment == 7
                      and y + 1.5 * line_height + start_percentage * max_height
                      or y - 1.5 * line_height - max_height * (1 - end_percentage)
       local height = max_height * (end_percentage - start_percentage)

        ass:new_event()
        ass:an(alignment)
        ass:append("{\\blur0\\4a&Hff&\\bord1}")
        ass:pos(x + max_item_width + opts.padding - 1, bar_y)
        ass:draw_start()
        ass:rect_cw(0, 0, -opts.padding / 2, height)
        ass:draw_stop()
    end

    ass:new_event()
    ass:an(alignment)
    ass:pos(x, y)
    if not selectable_items then
        ass:append(log_ass .. "\\N" .. completion_ass)
    end
    ass:append(style .. ass_escape(prompt) .. " " .. before_cur)
    ass:append(cglyph)
    ass:append(style .. after_cur)

    -- Redraw the cursor with the input text invisible. This will make the
    -- cursor appear in front of the text.
    ass:new_event()
    ass:an(alignment)
    ass:pos(x, y)
    ass:append(style .. "{\\alpha&HFF&}" .. ass_escape(prompt) .. " " .. before_cur)
    ass:append(cglyph)
    ass:append(style .. "{\\alpha&HFF&}" .. after_cur)

    -- z with selectable_items needs to be greater than the OSC's.
    update_overlay(ass.text, osd_w, osd_h, selectable_items and 2000 or 0)
end

local update_timer = nil
update_timer = mp.add_periodic_timer(0.05, function()
    if pending_update then
        render()
    else
        update_timer:kill()
    end
end)
update_timer:kill()

-- Add a line to the history and deduplicate
local function history_add(text)
    if history[#history] == text or text == "" then
        return
    end

    if opts.history_dedup then
        -- More recent entries are more likely to be repeated
        for i = #history, 1, -1 do
            if history[i] == text then
                table.remove(history, i)
                break
            end
        end
    end

    history[#history + 1] = text

    if history_paths[id] then
        histories_to_save[id] =  histories_to_save[id] .. text .. "\n"
    end
end

local function handle_cursor_move()
    -- Don't show completions after a command is entered because they move its
    -- output up, and allow clearing completions by emptying the line.
    if line == "" then
        completion_buffer = {}
        render()
    else
        complete()
    end
end

-- Filtering hundreds of thousands of items takes long enough to make typing
-- sluggish, so it is done in time slices of a coroutine which yields to the
-- event loop, letting pending keypresses be handled in between.
local filter_slice_duration = 0.005
-- The elapsed time is only checked once per this many items.
local filter_chunk_size = 1000

local filter_co

local function filter_items()
    local items = selectable_items
    local item_count = #items
    local needle = line
    local exact = opts.exact_match or needle:sub(1, 1) == "'"
    local case_sensitive = exact and opts.case_sensitive
    local words

    if exact then
        if not opts.exact_match then
            needle = needle:sub(2)
        end
        if not case_sensitive then
            needle = needle:lower()
        end

        words = {}
        for word in needle:gmatch("%S+") do
            words[#words + 1] = word
        end
    end

    local indices = {}
    local scores = not exact and needle ~= "" and {} or nil
    local lowered = selectable_items_lower
    local deadline = mp.get_time() + filter_slice_duration

    local first = 1
    while first <= item_count do
        local last = math.min(first + filter_chunk_size - 1, item_count)

        if needle == "" then
            for i = first, last do
                indices[i] = i
            end
        else
            if not case_sensitive then
                for i = first, last do
                    if not lowered[i] then
                        lowered[i] = items[i]:lower()
                    end
                end
            end

            if exact then
                for i = first, last do
                    local haystack = case_sensitive and items[i] or lowered[i]
                    local match = true
                    for w = 1, #words do
                        if not haystack:find(words[w], 1, true) then
                            match = false
                            break
                        end
                    end
                    if match then
                        indices[#indices + 1] = i
                    end
                end
            else
                fzy.filter_range(needle, items, first, last, indices, scores,
                                 false, lowered)
            end
        end

        first = last + 1

        if first <= item_count and mp.get_time() >= deadline then
            coroutine.yield()
            deadline = mp.get_time() + filter_slice_duration
        end
    end

    if scores then
        indices = sort_by_score(indices, scores)
    end

    matches = indices
    matches_query = { needle = needle, exact = exact, words = words,
                      case_sensitive = case_sensitive }

    if line == "" and default_item then
        focused_match = default_item

        local max_lines = calculate_max_lines()
        first_match_to_print = math.max(1, focused_match + 1 - math.ceil(max_lines / 2))
        if first_match_to_print > #selectable_items - max_lines + 1 then
            first_match_to_print = math.max(1, #selectable_items - max_lines + 1)
        end
    else
        focused_match = 1
    end

    render()
end

local filter_timer

local function step_filter()
    local ok, err = coroutine.resume(filter_co)
    if coroutine.status(filter_co) == "dead" then
        filter_timer:kill()
        filter_co = nil
        if not ok then
            error(err)
        end
    end
end

filter_timer = mp.add_periodic_timer(0, step_filter, true)

local function cancel_filter()
    filter_timer:kill()
    filter_co = nil
end

local function start_filter()
    cancel_filter()
    filter_co = coroutine.create(filter_items)

    -- Run the first slice synchronously so any smaller list is filtered
    -- instantly, and only huge workloads are deferred.
    step_filter()
    if filter_co then
        filter_timer:resume()
    end
end

local function handle_edit()
    if not selectable_items then
        cancel_filter()
        handle_cursor_move()
        mp.commandv("script-message-to", input_caller, input_caller_handler, "edited",
                    utils.format_json({line}))
        return
    end

    start_filter()

    -- Render new user input with previous matches, the matches will be updated
    -- once available.
    if filter_co then
        render()
    end
end

-- Insert a character at the current cursor position (any_unicode)
local function handle_char_input(c)
    if insert_mode then
        line = line:sub(1, cursor - 1) .. c .. line:sub(next_utf8(line, cursor))
    else
        line = line:sub(1, cursor - 1) .. c .. line:sub(cursor)
    end
    cursor = cursor + #c
    handle_edit()
end

-- Remove the character behind the cursor (Backspace)
local function handle_backspace()
    if cursor <= 1 then return end
    local prev = prev_utf8(line, cursor)
    line = line:sub(1, prev - 1) .. line:sub(cursor)
    cursor = prev
    handle_edit()
end

-- Remove the character in front of the cursor (Del)
local function handle_del()
    if cursor > line:len() then return end
    line = line:sub(1, cursor - 1) .. line:sub(next_utf8(line, cursor))
    handle_edit()
end

-- Toggle insert mode (Ins)
local function handle_ins()
    insert_mode = not insert_mode
end

-- Move the cursor to the next character (Right)
local function next_char()
    cursor = next_utf8(line, cursor)
    handle_cursor_move()
end

-- Move the cursor to the previous character (Left)
local function prev_char()
    cursor = prev_utf8(line, cursor)
    handle_cursor_move()
end

-- Clear the current line (Ctrl+C)
local function clear()
    line = ""
    cursor = 1
    insert_mode = false
    history_pos = #history + 1
    handle_edit()
end

-- Close the console if the current line is empty, otherwise delete the next
-- character (Ctrl+D)
local function maybe_exit()
    if line == "" then
        set_active(false)
    else
        handle_del()
    end
end

local function unbind_mouse()
    mp.remove_key_binding("_console_mouse_move")
    mp.remove_key_binding("_console_mbtn_left")
end

local function after_cur_matches_completion()
    local token = line:sub(completion_pos)

    for _, completion in pairs(completion_buffer) do
        if completion == token:sub(1, #completion) then
            return true
        end
    end
end

-- Run the current command or select the current item
local function submit()
    if searching_history then
        searching_history = false
        line = #matches > 0 and selectable_items[matches[focused_match]] or ""
        selectable_items = nil
        selectable_items_lower = nil
        cursor = #line + 1
        handle_edit()
        unbind_mouse()
        return
    end

    if selectable_items then
        if #matches > 0 then
            mp.commandv("script-message-to", input_caller, input_caller_handler, "submit",
                        utils.format_json({matches[focused_match]}))
        end
    else
        if selected_completion_index == 0 and autoselect_completion
           and not after_cur_matches_completion() then
            cycle_through_completions()
        end

        mp.commandv("script-message-to", input_caller, input_caller_handler, "submit",
                    utils.format_json({line}))

        history_add(line)
    end

    if not keep_open then
        set_active(false)
    elseif not selectable_items then
        clear()
    end
end

local function determine_hovered_item()
    local osd_w, _ = get_scaled_osd_dimensions()
    local scale = scale_factor()
    local mouse_pos = mp.get_property_native("mouse-pos")
    local mouse_x = mouse_pos.x / scale
    local mouse_y = mouse_pos.y / scale
    local item_x0 = (searching_history and get_margin_x() or (osd_w - max_item_width) / 2)
                    - opts.padding

    if mouse_x < item_x0 or mouse_x > item_x0 + max_item_width + opts.padding * 2 then
        return
    end

    for i, positions in ipairs(item_positions) do
        if mouse_y >= positions[1] and mouse_y <= positions[2] then
            return first_match_to_print - 1 + i
        end
    end
end

local function bind_mouse()
    mp.add_forced_key_binding("MOUSE_MOVE", "_console_mouse_move", function()
        local item = determine_hovered_item()
        if item and item ~= focused_match then
            focused_match = item
            render()
        end
    end)

    mp.add_forced_key_binding("MBTN_LEFT", "_console_mbtn_left", function()
        local item = determine_hovered_item()
        if item then
            focused_match = item
            submit()
        else
            set_active(false)
        end
    end)
end

-- Go to the specified position in the command history
local function go_history(new_pos)
    local old_pos = history_pos
    history_pos = new_pos

    -- Restrict the position to a legal value
    if history_pos > #history + 1 then
        history_pos = #history + 1
    elseif history_pos < 1 then
        history_pos = 1
    end

    -- Do nothing if the history position didn't actually change
    if history_pos == old_pos then
        return
    end

    -- If the user was editing a non-history line, save it as the last history
    -- entry. This makes it much less frustrating to accidentally hit Up/Down
    -- while editing a line.
    if old_pos == #history + 1 then
        history_add(line)
    end

    -- Now show the history line (or a blank line for #history + 1)
    if history_pos <= #history then
        line = history[history_pos]
    else
        line = ""
    end
    cursor = line:len() + 1
    insert_mode = false
    handle_edit()
end

-- Go to the specified relative position in the command history (Up, Down)
local function move_history(amount, is_wheel)
    if not selectable_items then
        go_history(history_pos + amount)
        return
    end

    if is_wheel then
        local max_lines = calculate_max_lines()

        -- Update focused_match only if it's the first or last printed item and
        -- there are hidden items.
        if (amount > 0 and focused_match == first_match_to_print
            and first_match_to_print - 1 + max_lines < #matches)
           or (amount < 0 and focused_match == first_match_to_print - 1 + max_lines
               and first_match_to_print > 1) then
            focused_match = focused_match + amount
        end

        if amount > 0 and first_match_to_print < #matches - max_lines + 1
           or amount < 0 and first_match_to_print > 1 then
           -- math.min and math.max would only be needed with amounts other than
           -- 1 and -1.
            first_match_to_print = math.min(
                math.max(first_match_to_print + amount, 1), #matches - max_lines + 1)
        end

        local item = determine_hovered_item()
        if item then
            focused_match = item
        end

        render()
        return
    end

    focused_match = focused_match + amount
    if focused_match > #matches then
        focused_match = 1
    elseif focused_match < 1 then
        focused_match = #matches
    end
    render()
end

local function horizontal_scroll(amount)
    horizontal_offset = math.max(horizontal_offset + amount, 0)
    render()
end

-- Go to the first command in the command history (PgUp)
local function handle_pgup()
    if selectable_items then
        focused_match = math.max(focused_match - calculate_max_lines() + 1, 1)
        render()
        return
    end

    go_history(1)
end

-- Stop browsing history and start editing a blank line (PgDown)
local function handle_pgdown()
    if selectable_items then
        focused_match = math.min(focused_match + calculate_max_lines() - 1, #matches)
        render()
        return
    end

    go_history(#history + 1)
end

local function search_history()
    if selectable_items or #history == 0 then
        return
    end

    searching_history = true
    selectable_items = {}
    selectable_items_lower = {}
    horizontal_offset = 0

    for i = 1, #history do
        selectable_items[i] = history[#history + 1 - i]
    end

    calculate_max_item_width()
    handle_edit()
    bind_mouse()
end

local function page_up_or_prev_char()
    if selectable_items then
        handle_pgup()
    else
        prev_char()
    end
end

local function page_down_or_next_char()
    if selectable_items then
        handle_pgdown()
    else
        next_char()
    end
end

-- Move to the start of the current word, or if already at the start, the start
-- of the previous word. (Ctrl+Left)
local function prev_word()
    -- This is basically the same as next_word() but backwards, so reverse the
    -- string in order to do a "backwards" find. This wouldn't be as annoying
    -- to do if Lua didn't insist on 1-based indexing.
    cursor = line:len() - select(2, line:reverse():find("%s*[^%s]*", line:len() - cursor + 2)) + 1
    handle_cursor_move()
end

-- Move to the end of the current word, or if already at the end, the end of
-- the next word. (Ctrl+Right)
local function next_word()
    cursor = select(2, line:find("%s*[^%s]*", cursor)) + 1
    handle_cursor_move()
end

-- Move the cursor to the beginning of the line (HOME)
local function go_home()
    cursor = 1
    handle_cursor_move()
end

-- Move the cursor to the end of the line (END)
local function go_end()
    cursor = line:len() + 1
    handle_cursor_move()
end

-- Delete from the cursor to the beginning of the word (Ctrl+Backspace)
local function del_word()
    local before_cur = line:sub(1, cursor - 1)
    local after_cur = line:sub(cursor)

    before_cur = before_cur:gsub("[^%s]+%s*$", "", 1)
    line = before_cur .. after_cur
    cursor = before_cur:len() + 1
    handle_edit()
end

-- Delete from the cursor to the end of the word (Ctrl+Del)
local function del_next_word()
    if cursor > line:len() then return end

    local before_cur = line:sub(1, cursor - 1)
    local after_cur = line:sub(cursor)

    after_cur = after_cur:gsub("^%s*[^%s]+", "", 1)
    line = before_cur .. after_cur
    handle_edit()
end

-- Delete from the cursor to the end of the line (Ctrl+K)
local function del_to_eol()
    line = line:sub(1, cursor - 1)
    handle_edit()
end

-- Delete from the cursor back to the start of the line (Ctrl+U)
local function del_to_start()
    line = line:sub(cursor)
    cursor = 1
    handle_edit()
end

-- Empty the log buffer of all messages (Ctrl+L)
local function clear_log_buffer()
    if not selectable_items then
        log_buffers[id] = {}
    end
    render()
end

local function scroll_log(amount)
    if selectable_items then
        return
    end

    log_offset = log_offset + amount
    render()
end

-- Returns a string of UTF-8 text from the clipboard (or the primary selection)
local function get_clipboard(clip)
    mp.commandv("update-clipboard", clip and "text" or "text-primary")
    if platform == "x11" then
        local property = clip and "clipboard/text" or "clipboard/text-primary"
        return mp.get_property(property, "")
    elseif platform == "wayland" then
        if mp.get_property("current-clipboard-backend") == "wayland" then
            local property = clip and "clipboard/text" or "clipboard/text-primary"
            return mp.get_property(property, "")
        end
        -- Wayland VO clipboard is only updated on window focus
        if clip and get_property_cached("focused") then
            return mp.get_property("clipboard/text", "")
        end
        local res = utils.subprocess({
            args = { "wl-paste", clip and "-n" or  "-np" },
            playback_only = false,
        })
        if not res.error then
            return res.stdout
        end
    elseif platform == "windows" or platform == "darwin" then
        return mp.get_property("clipboard/text", "")
    end
    return ""
end

-- Paste text from the window-system's clipboard. "clip" determines whether the
-- clipboard or the primary selection buffer is used (on X11 and Wayland only.)
local function paste(clip)
    local text = get_clipboard(clip)
    if selectable_items then
        text = text:gsub("\r?\n", " ")
    end
    local before_cur = line:sub(1, cursor - 1)
    local after_cur = line:sub(cursor)
    line = before_cur .. text .. after_cur
    cursor = cursor + text:len()
    handle_edit()
end

local function copy()
    if not selectable_items then
        mp.set_property("clipboard/text", line)
        mp.msg.info("Input line copied")
    elseif matches[1] then
        mp.set_property("clipboard/text", selectable_items[matches[focused_match]])

        if terminal_output() then
            mp.msg.info("Item copied")
        else
            mp.osd_message("Item copied")
        end
    end
end

local function text_input(info)
    if info.key_text and (info.event == "press" or info.event == "down"
                          or info.event == "repeat")
    then
        handle_char_input(info.key_text)
    end
end


local function common_prefix_length(s1, s2)
    local common_count = 0
    for i = 1, #s1 do
        if s1:byte(i) ~= s2:byte(i) then
            break
        end
        common_count = common_count + 1
    end
    return common_count
end

local function max_overlap_length(s1, s2)
    for s1_offset = 0, #s1 - 1 do
        local match = true
        for i = 1, #s1 - s1_offset do
            if s1:byte(s1_offset + i) ~= s2:byte(i) then
                match = false
                break
            end
        end
        if match then
            return #s1 - s1_offset
        end
    end
    return 0
end

-- If str starts with the first or last characters of prefix, strip them.
local function strip_common_characters(str, prefix)
    return str:sub(1 + math.max(
    common_prefix_length(prefix, str),
    max_overlap_length(prefix, str)))
end

cycle_through_completions = function (backwards)
    -- Disable tab completions while waiting for completion responses
    if completion_timer:is_enabled() or dim_completions then
        return
    end

    if #completion_buffer == 0 then
        -- Allow Tab completion of commands before typing anything.
        if line == "" then
            complete()
        end

        return
    end

    selected_completion_index = selected_completion_index + (backwards and -1 or 1)

    if selected_completion_index > #completion_buffer then
        selected_completion_index = 1
    elseif selected_completion_index < 1 then
        selected_completion_index = #completion_buffer
    end

    local before_cur = line:sub(1, completion_pos - 1) ..
                       completion_buffer[selected_completion_index] .. completion_append
    line = before_cur .. strip_common_characters(line:sub(cursor),
        completion_buffer[selected_completion_index] .. completion_append)
    cursor = before_cur:len() + 1
    render()
end

local function enable_completions()
    dim_completions = false
    completion_timer:kill()
end

-- Show autocompletions.
complete = function ()
    completion_timer:resume()
    mp.commandv("script-message-to", input_caller, input_caller_handler,
                "complete", utils.format_json({line:sub(1, cursor - 1)}))
    render()
end

-- List of input bindings. This is a weird mashup between common GUI text-input
-- bindings and readline bindings.
local function get_bindings()
    local bindings = {
        { "esc",         function() set_active(false) end       },
        { "ctrl+[",      function() set_active(false) end       },
        { "enter",       submit                                 },
        { "kp_enter",    submit                                 },
        { "shift+enter", function() if not selectable_items then handle_char_input("\n") end end },
        { "ctrl+j",      submit                                 },
        { "ctrl+m",      submit                                 },
        { "bs",          handle_backspace                       },
        { "shift+bs",    handle_backspace                       },
        { "ctrl+h",      handle_backspace                       },
        { "del",         handle_del                             },
        { "shift+del",   handle_del                             },
        { "ins",         handle_ins                             },
        { "shift+ins",   function() paste(false) end            },
        { "mbtn_mid",    function() paste(false) end            },
        { "left",        function() prev_char() end             },
        { "ctrl+b",      function() page_up_or_prev_char() end  },
        { "right",       function() next_char() end             },
        { "ctrl+f",      function() page_down_or_next_char() end},
        { "up",          function() move_history(-1) end        },
        { "ctrl+p",      function() move_history(-1) end        },
        { "wheel_up",    function() move_history(-1, true) end  },
        { "down",        function() move_history(1) end         },
        { "ctrl+n",      function() move_history(1) end         },
        { "wheel_down",  function() move_history(1, true) end   },
        { "shift+up",    function() scroll_log(1)  end          },
        { "shift+down",  function() scroll_log(-1) end          },
        { "wheel_left",  function() end                         },
        { "wheel_right", function() end                         },
        { "shift+left",  function() horizontal_scroll(-25) end  },
        { "shift+right", function() horizontal_scroll( 25) end  },
        { "wheel_left",  function() horizontal_scroll(-25) end  },
        { "wheel_right", function() horizontal_scroll( 25) end  },
        { "shift+wheel_up",   function() horizontal_scroll(-25) end },
        { "shift+wheel_down", function() horizontal_scroll( 25) end },
        { "ctrl+left",   prev_word                              },
        { "alt+b",       prev_word                              },
        { "ctrl+right",  next_word                              },
        { "alt+f",       next_word                              },
        { "tab",         cycle_through_completions              },
        { "ctrl+i",      cycle_through_completions              },
        { "shift+tab",   function() cycle_through_completions(true) end },
        { "ctrl+a",      go_home                                },
        { "home",        go_home                                },
        { "ctrl+e",      go_end                                 },
        { "end",         go_end                                 },
        { "pgup",        handle_pgup                            },
        { "pgdwn",       handle_pgdown                          },
        { "ctrl+r",      search_history                         },
        { "ctrl+c",      clear                                  },
        { "ctrl+d",      maybe_exit                             },
        { "ctrl+k",      del_to_eol                             },
        { "ctrl+l",      clear_log_buffer                       },
        { "ctrl+u",      del_to_start                           },
        { "ctrl+y",      copy,                                  },
        { "ctrl+v",      function() paste(true) end             },
        { "meta+v",      function() paste(true) end             },
        { "ctrl+bs",     del_word                               },
        { "ctrl+w",      del_word                               },
        { "ctrl+del",    del_next_word                          },
        { "alt+d",       del_next_word                          },
        { "kp_dec",      function() handle_char_input(".") end  },
        { "kp_add",      function() handle_char_input("+") end  },
        { "kp_subtract", function() handle_char_input("-") end  },
        { "kp_multiply", function() handle_char_input("*") end  },
        { "kp_divide",   function() handle_char_input("/") end  },
    }

    for i = 0, 9 do
        bindings[#bindings + 1] =
            {"kp" .. i, function() handle_char_input("" .. i) end}
    end

    return bindings
end

local function define_key_bindings()
    if #key_bindings > 0 then
        return
    end
    for _, bind in ipairs(get_bindings()) do
        -- Generate arbitrary name for removing the bindings later.
        local name = "_console_" .. (#key_bindings + 1)
        key_bindings[#key_bindings + 1] = name
        mp.add_forced_key_binding(bind[1], name, bind[2], {repeatable = true})
    end
    mp.add_forced_key_binding("any_unicode", "_console_text", text_input,
        {repeatable = true, complex = true})
    key_bindings[#key_bindings + 1] = "_console_text"
end

local function undefine_key_bindings()
    for _, name in ipairs(key_bindings) do
        mp.remove_key_binding(name)
    end
    key_bindings = {}
end

local function read_history()
    if not history_paths[id] or history[1] then
        return
    end

    local history_file = io.open(mp.command_native({"expand-path", history_paths[id]}))

    if history_file == nil then
        return
    end

    if opts.history_dedup then
        local unfiltered_history = {}
        for command in history_file:lines() do
            unfiltered_history[#unfiltered_history + 1] = command
        end

        local history_map = {}
        for i = #unfiltered_history, 1, -1 do
            local command = unfiltered_history[i]
            if not history_map[command] then
                history[#history + 1] = command
                history_map[command] = true
            end
        end

        for i = 1, #history / 2, 1 do
            history[i], history[#history - i + 1] = history[#history - i + 1], history[i]
        end
    else
        for command in history_file:lines() do
            history[#history + 1] = command
        end
    end

    history_file:close()
end

-- Open or close the console
set_active = function (active)
    if active == open then
        return
    end

    if active then
        open = true
        insert_mode = false
        define_key_bindings()
        mp.set_property_bool("user-data/mpv/console/open", true)
        ime_active = mp.get_property_bool("input-ime")
        mp.set_property_bool("input-ime", true)
    elseif searching_history then
        searching_history = false
        cancel_filter()
        selectable_items = nil
        selectable_items_lower = nil
        unbind_mouse()
    else
        open = false
        undefine_key_bindings()
        unbind_mouse()
        completion_timer:kill()
        cancel_filter()
        mp.set_property_bool("user-data/mpv/console/open", false)
        mp.set_property_bool("input-ime", ime_active)
        mp.commandv("script-message-to", input_caller, input_caller_handler,
                    "closed", utils.format_json({line, cursor}))

        -- these tables may be extremely large, reset them for garbage collection
        selectable_items = nil
        selectable_items_lower = nil
        matches = {}
        matches_query = { needle = "" }
        item_positions = {}
        completion_buffer = {}
        fzy.gc()

        collectgarbage()
    end
    render()
end

mp.register_script_message("disable", function(message)
    message = utils.parse_json(message or "")

    if not message or message.client_name == input_caller then
        set_active(false)
    end
end)

mp.register_script_message("get-input", function (args)
    args = utils.parse_json(args)
    if type(args) ~= "table" then
        return msg.error("Input request aborted - " ..
                         "get-input must be passed a JSON formatted table.")
    end

    if not args.client_name or not args.handler_id then
        return msg.error("Input request aborted - " ..
                         "get-input must be passed a 'client_name' and 'handler_id'")
    end

    if open then
        mp.commandv("script-message-to", input_caller, input_caller_handler,
                    "closed", utils.format_json({line, cursor}))
    end

    input_caller = args.client_name
    input_caller_handler = args.handler_id
    prompt = to_string(args.prompt or "")
    line = to_string(args.default_text or "")
    cursor = math.floor(tonumber(args.cursor_position) or line:len() + 1)
    keep_open = args.keep_open
    default_item = args.default_item
    has_completions = args.has_completions
    searching_history = false
    if cursor < 1 then
        cursor = 1
    elseif cursor > line:len() + 1 then
        cursor = line:len() + 1
    end

    -- Allows clients to override script-opts, but first ensures the overrides are the same type.
    if type(args.console_opt_overrides) == "table" then
        for k, v in pairs(args.console_opt_overrides) do
            if type(v) ~= type(script_opts[k]) then
                args.console_opt_overrides[k] = nil
                msg.warn(("mp.input (%s): ignoring console_opt_overrides.%s - must be of type %s"
                         ):format(input_caller, k, type(script_opts[k])))
            end
        end
        opts = setmetatable(args.console_opt_overrides, {__index = script_opts})
    else
        opts = script_opts
    end

    enable_completions()

    if args.items then
        if type(args.items) ~= "table" then
            msg.warn(("input.select (%s): 'items' must be a table, instead received a %s"
                     ):format(input_caller, type(args.items)))
            -- set as empty to avoid errors
            args.items = {}
        end

        selectable_items = {}
        selectable_items_lower = {}
        horizontal_offset = 0

        -- Limit the number of characters to prevent libass from freezing mpv.
        -- Not important for terminal output.
        local limit
        if osd_width == 0 or terminal_output() then
            limit = 5000
        else
            limit = 5 * osd_width / opts.font_size
        end

        for i, item in ipairs(args.items) do
            item = to_string(item)
            local last = next_utf8(item, limit) - 1
            selectable_items[i] = item:gsub("[\r\n].*", "…"):sub(1, last) ..
                                  (last < #item and "…" or "")
        end

        calculate_max_item_width()
        handle_edit()
        bind_mouse()
    else
        cancel_filter()
        selectable_items = nil
        selectable_items_lower = nil
        unbind_mouse()
        id = args.id or input_caller .. args.prompt
        log_offset = 0
        completion_buffer = {}
        autoselect_completion = args.autoselect_completion

        if histories[id] == nil then
            histories[id] = {}
            log_buffers[id] = {}
            histories_to_save[id] = ""
        end
        history = histories[id]

        -- We probably do not want to be converting accidental non-string values to filepaths.
        history_paths[id] = type(args.history_path) == "string" and args.history_path or nil
        read_history()
        history_pos = #history + 1

        handle_cursor_move()
    end

    set_active(true)

    -- We want to ensure the keybindings have been set before sending the "opened" event
    -- in case scripts want to override our bindings.
    mp.flush_keybindings()
    mp.commandv("script-message-to", input_caller, input_caller_handler, "opened")
end)

-- Add a line to the log buffer
mp.register_script_message("log", function (message)
    message = utils.parse_json(message or "")
    if type(message) ~= "table" or not message.log_id then
        return msg.error("Line not appended to log buffer - " ..
                         "log must be passed a JSON formatted table which contains a 'log_id'.")
    end

    local log_buffer = log_buffers[message.log_id]
    if not log_buffer then return end

    log_buffer[#log_buffer + 1] = {
        text = to_string(message.text or ""),
        style = message.error and styles.error or to_string(message.style or ""),
        terminal_style = message.error and terminal_styles.error or
                         to_string(message.terminal_style or ""),
    }

    if #log_buffer > MAX_LOG_LINES then
        table.remove(log_buffer, 1)
    end

    if not open or message.log_id ~= id then
        return
    end

    if log_offset > 0 then
        log_offset = log_offset + 1
    end

    if not update_timer:is_enabled() then
        render()
        update_timer:resume()
    else
        pending_update = true
    end
end)

mp.register_script_message("set-log", function (log_id, log)
    log = utils.parse_json(log)
    if not log_id then
        return msg.error("Log buffer not overwritten - " ..
                         "set-log must be passed a 'log_id' as the first argument.")
    end

    -- This error message provides extra details as mp.input passes the `log` table to
    -- console.lua unchanged, meaning that this guard may be triggered by Lua and Js clients.
    if type(log) ~= "table" then
        return msg.error("Log buffer not overwritten - " ..
                         "set-log must be passed a table as second argument, " ..
                         ("instead received a %s."):format(type(log)))
    end

    log_buffers[log_id] = {}

    for i = 1, #log do
        if type(log[i]) == "table" then
            log[i].text = to_string(log[i].text or "")
            log[i].style = to_string(log[i].style or "")
            log[i].terminal_style = to_string(log[i].terminal_style or "")
            log_buffers[id][i] = log[i]
        else
            log_buffers[id][i] = {
                text = to_string(log[i] or ""),
                style = "",
                terminal_style = "",
            }
        end
    end

    if log_id == id then
        render()
    end
end)

mp.register_script_message("complete", function (message)
    message = utils.parse_json(message)
    if type(message) ~= "table" then
        return msg.error("Completions not received - " ..
                         "complete must be passed a JSON formatted table.")
    end

    if message.client_name ~= input_caller or message.handler_id ~= input_caller_handler then
        return
    end

    completion_buffer = {}
    selected_completion_index = 0
    local completions = message.list

    if type(completions) ~= "table" then
        msg.warn(("Completions invalid (%s) - " ..
                  "completions must be a table, instead received a %s"
                 ):format(input_caller, type(completions)))
        completions = {}
    else
        for i = 1, #completions do
            completions[i] = to_string(completions[i])
        end
    end

    table.sort(completions)
    completion_pos = math.floor(tonumber(message.start_pos) or 1)
    completion_append = to_string(message.append or "")
    if completion_pos < 1 then
        completion_pos = 1
    end

    for i, match_index in ipairs(fuzzy_find(line:sub(completion_pos, cursor - 1),
                                            completions)) do
        completion_buffer[i] = completions[match_index]
    end

    -- Rendering outdated completions makes the completions appear more
    -- responsive, but we only want allow selecting up-to-date completions.
    if line:sub(1, cursor - 1) == message.original_line then
        enable_completions()
    end
    render()
end)

local function resize()
    calculate_max_item_width()
    render()
end

mp.observe_property("osd-dimensions", "native", function (_, value)
    if value.w == osd_width and value.h == osd_height then
        return
    end
    osd_width, osd_height = value.w, value.h
    resize()
end)

mp.observe_property("display-hidpi-scale", "native", function (name, value)
    property_cache[name] = value
    resize()
end)

mp.observe_property("focused", "native", function (name, value)
    property_cache[name] = value
    render()
end)

mp.observe_property("user-data/osc/margins", "native", function(_, val)
    if type(val) == "table" and type(val.t) == "number" and type(val.b) == "number" then
        global_margins = val
    else
        global_margins = { t = 0, b = 0 }
    end
    render()
end)

mp.register_event("shutdown", function ()
    mp.del_property("user-data/mpv/console")

    for history_id, history_path in pairs(history_paths) do
        history_path = mp.command_native({"expand-path", history_path})
        local history_file, error_message = io.open(history_path, "ab")

        if history_file then
            history_file:write(histories_to_save[history_id])
            history_file:close()
        else
            mp.msg.error("Failed to write history: " .. error_message)
        end
    end
end)

-- These are for backwards compatibility only.
mp.add_key_binding(nil, "enable", function ()
    mp.command("script-message-to commands open")
end)

mp.register_script_message("type", function (...)
    mp.commandv("script-message-to", "commands", "type", ...)
end)

require "mp.options".read_options(script_opts, nil, render)

collectgarbage()
