--[[
This script uses the lavfi cropdetect filter to automatically insert a crop filter with appropriate parameters
    for the currently playing video, the script run continuously by default (mode 4).

To use this script, "hwdec=no" (mpv default/recommended) or any "-copy" variant like "hwdec=auto-copy" is required,
    consider editing "mpv.conf" to an appropriate value.

The workflow is as follows: We observe ffmpeg log to collect metadata and process it.
    Collected metadata are stored sequentially in s.buffer, then process to check and
    store trusted values to speed up future change for the current video.
    It will automatically crop the video as soon as a change is validated.

The default options can be overridden by adding a line into "mpv.conf" with:
    script-opts-append=<script_name>-<parameter>=<value>
    script-opts-append=dynamic_crop-mode=0
    script-opts-append=dynamic_crop-ratios=2.4 2.39 2 4/3 (quotes aren't needed like below)

Extended descriptions for some parameters (For default values, see <options>):

mode: [0-4] 0 disable, 1 on-demand, 2 one-shot, 3 dynamic-manual, 4 dynamic-auto
    Mode 1 and 3 requires using the shortcut to start, 2 and 4 have an automatic start.

Shortcut "C" (shift+c) to control the script.
Cycle between ENABLE / DISABLE_WITH_CROP / DISABLE

prevent_change_mode: [0-3] 0 disable 1 keep-largest, 2 keep-lowest, 3 keep-latest
    The prevent_change_timer is trigger after a change.

fix_windowed_behavior: [0-3] Avoid the default behavior that resizes the window to the source size
    when the crop filter changes in windowed/maximized mode by adjusting geometry.

limit_timer: Only used if the cropdetect filter doesn't handle limit changes with a command (patch 01/2023).
    Extend the time between each limit change to reduce the impact on performance caused by re-initializing the
    full filter.

read_ahead_mode: Linked to the associated timer and tells how much time in advance to collect the metadata.
    This feature is useful for videos with multiple aspect ratio changes for "fast_change_timer".
    Note: because this function is in sync with the playback, a delay equivalent to the timer used is
    added/reset every time you seek before you get a reaction, so setting 1 is recommanded.
    Required at least https://github.com/FFmpeg/FFmpeg/commit/69c060bea21d3b4ce63b5fff40d37e98c70ab88f
    and optionally https://github.com/mpv-player/mpv/pull/11182, until mpv patch is being merged to master,
    considered this feature experimental because of the errors generated in logs/console by vf-command and
    the filter used to sync the filter chain (psnr).

read_ahead_sync: Compensates for the delay when applying the crop filter and the visible result.
    Must be adjusted to your tastes and each MPV client depending on their reaction time.
    Note: Perfect adjustment is not really possible but generally <= 1 frame, sometimes more in
    dark/ambiguous scenes.

segmentation: e.g. 0.5 for 50% - Extra time to allow new metadata to be segmented instead of being continuous.
    This is used with ratio_timer, offset_timer and fallback_timer.
    e.g. ratio_timer is validated with 5 sec accumulated over 7.5 sec elapsed.
]] --
require "mp.options"

-- options
local options = {
    -- behavior
    mode = 4, -- [0-4] more details above
    start_delay = 0, -- delay in seconds used to skip intro (usefull with mode 2)
    prevent_change_timer = 30, -- seconds
    prevent_change_mode = 0, -- [0-3], more details above
    fix_windowed_behavior = 1, -- [0-3], 0 no-fix, 1 fix-no-resize, 2 fix-keep-width, 3 fix-keep-height
    limit_timer = 0.5, -- seconds, 0 disable, more details above
    fast_change_timer = 0.2, -- seconds, recommanded to keep default or > 0 if read_ahead is supported by mpv
    ratio_timer = 2, -- seconds, meta in ratios list
    offset_timer = 20, -- seconds, >= 'ratio_timer', new offset for asymmetric video
    fallback_timer = 40, -- seconds, >= 'offset_timer', not in ratios list and possibly with new offset
    linked_tolerance = 2, -- int, scale with detect_round to match against source width/height
    ratios = "2.76 2.55 24/9 2.4 2.39 2.35 2.2 2.1 2 1.9 1.85 16/9 5/3 1.5 1.43 4/3 1.25 9/16", -- list
    ratio_tolerance = 2, -- int (even number), adjust in order to match more easly the ratios list
    read_ahead_mode = 0, -- [0-2], 0 disable, 1 fast_change_timer, 2 ratio_timer, more details above
    read_ahead_sync = 0, -- int/frame, increase for advance, more details above
    segmentation = 0.5, -- [0.0-1] %, 0 will approved only a continuous metadata (strict)
    crop_method = 1, -- 0 lavfi-crop (ffmpeg/filter), 1 video-crop (mpv/VO)
    -- filter, see https://ffmpeg.org/ffmpeg-filters.html#cropdetect for details
    detect_limit = 26, -- is the maximum use, increase it slowly if lighter black are present
    detect_round = 2, -- even number
    -- verbose
    debug = false
}
read_options(options)

if options.mode == 0 then
    mp.msg.info("mode = 0, disable script.")
    return
end

-- forward declarations
local cleanup, on_toggle
local s = {}

-- labels
local label_prefix = mp.get_script_name()
local labels = {
    crop = string.format("%s-crop", label_prefix), cropdetect = string.format("%s-cropdetect", label_prefix)
}

-- shifting decimal to
local LEFT, RIGHT = true, false
local function shifting_to(left, value)
    local shift = 1e3
    return left and (value / shift) or value >= 1 and math.ceil(value * shift) or value * shift
end

-- options: compute timer and other stuff
for k, v in pairs(options) do
    local timer = string.match(tostring(k), "_timer")
    if timer then options[k] = shifting_to(RIGHT, v) end
end
options.read_ahead_timer =
    options.read_ahead_mode == 1 and options.fast_change_timer or options.read_ahead_mode == 2 and options.ratio_timer *
        (1 + options.segmentation) or nil
options.read_ahead_cropdetect = options.read_ahead_timer and shifting_to(LEFT, options.read_ahead_timer) or nil
options.reverse_segmentation = 1 / (1 * (1 + options.segmentation))
options.crop_method_sync = options.crop_method == 0 and 1 or 0 -- lavfi-crop is slower, so give it some advance for read_ahead

local function print_debug(msg_type, meta, label)
    if not options.debug then
        return
    elseif msg_type == "pre_format" then
        mp.msg.info(meta)
    elseif msg_type == "metadata" then
        mp.msg.info(string.format("%s, %-29s | offX:%3s offY:%3s | limit:%-2s", label, meta.whxy, meta.offset.x,
            meta.offset.y, s.limit.current))
    elseif msg_type == "buffer" and s.stats.buffer then
        mp.msg.info("Buffer stats:")
        for whxy, ref in pairs(s.stats.buffer) do
            mp.msg.info(string.format(
                "\\ %-29s | offX=%4s offY=%4s | time=%6ss linked_source=%-4s known_ratio=%-4s trusted_offsets=%s", whxy,
                ref.offset.x, ref.offset.y, shifting_to(LEFT, ref.time.buffer), ref.is_linked_to_source or false,
                ref.is_known_ratio or false, ref.is_trusted_offsets))
        end
        mp.msg.info("Buffer list:")
        for i, v in ipairs(s.buffer.indexed_list) do
            local new_ref = v.new_ref and v.new_ref.whxy or ""
            local pts = shifting_to(RIGHT, v.pts)
            mp.msg.info(string.format("\\ %3s %-29s %4sms pts:%d new_ref:%s", i, v.ref.whxy, v.t_elapsed, pts, new_ref))
        end
        mp.msg.info("i_fallback", s.candidate.i_fallback)
        mp.msg.info("i_offset", s.candidate.i_offset)
    elseif msg_type == "applied" and s.stats.indexed_applied then
        mp.msg.info("Applied list:")
        for i, v in ipairs(s.stats.indexed_applied) do
            mp.msg.info(string.format("\\ %3s %-29s pts:%d", i, v.ref.whxy, shifting_to(RIGHT, v.pts)))
        end
    end
end

local function print_stats()
    if not s.stats and not s.stats.trusted then return end
    mp.msg.info("Meta Stats:")
    local offsets_list = {x = "", y = ""}
    for axis, _ in pairs(offsets_list) do
        for _, v in pairs(s.stats.trusted_offset[axis]) do offsets_list[axis] = offsets_list[axis] .. v .. " " end
    end
    mp.msg.info(
        string.format("Limit - min/max: %s/%s | counter: %s", s.limit.min, options.detect_limit, s.limit.counter))
    mp.msg.info(string.format("Trusted - unique: %s | offset: X:%sY:%s", s.stats.trusted_unique, offsets_list.x,
        offsets_list.y))
    for whxy, ref in pairs(s.stats.trusted) do
        if s.stats.trusted[whxy] then
            mp.msg.info(string.format("\\ %-29s | offX=%3s offY=%3s | applied=%s overall=%ss accumulated=%ss", whxy,
                ref.offset.x, ref.offset.y, ref.applied, shifting_to(LEFT, ref.time.overall),
                shifting_to(LEFT, ref.time.accumulated)))
        end
    end
    mp.msg.info("Buffer - unique: " .. s.stats.buffer_unique .. " | total: " .. s.buffer.i_total,
        shifting_to(LEFT, s.buffer.t_total) .. "s | known_ratio:", s.buffer.i_ratio,
        shifting_to(LEFT, s.buffer.t_ratio) .. "s")
end

local function is_trusted_offset(offset, axis)
    local trusted_offset = s.stats.trusted_offset[axis]
    for _, v in ipairs(trusted_offset) do if math.abs(offset - v) <= 1 then return true end end
    return false
end

local function is_cropable()
    for _, track in pairs(mp.get_property_native('track-list')) do
        if track.type == 'video' and track.selected then return not track.albumart end
    end
    return false
end

local function filter_state(label, key, value)
    local filters = mp.get_property_native("vf")
    for _, filter in pairs(filters) do
        if filter["label"] == label and
            ((not key or key ~= "graph" and filter[key] == value or key == "graph" and
                string.find(filter.params.graph, value))) then return true end
    end
    return false
end

local function command_filter(label, command, argument, target)
    if not s.f_vfcommand then
        local res, reason = mp.commandv("vf-command", label, command, argument, target)
        if not res and reason == "invalid parameter" then
            s.f_vfcommand = true -- if mpv doesn't handle target parameter
        end
    end
    if s.f_vfcommand then
        -- fallback and send to all filters inside the graph
        mp.commandv("vf-command", label, command, argument)
    end
end

local function insert_cropdetect_filter(limit, change)
    if s.toggled > 1 or s.paused then return end
    local function insert_filter()
        local cropdetect = string.format("cropdetect@dyn_cd=limit=%d/255:round=%d:reset=1", limit, options.detect_round)
        if s.f_limit_runtime and change then
            command_filter(labels.cropdetect, "limit", string.format("%d/255", limit), "cropdetect")
            return true
        elseif s.f_limit_runtime and options.read_ahead_mode > 0 then
            return mp.commandv("vf", "pre",
                string.format("@%s:lavfi=[split[a][b];[b]setpts=PTS-%s/TB,%s[b];%s]", labels.cropdetect,
                    options.read_ahead_cropdetect, cropdetect, s.f_sync))
        else
            return mp.commandv("vf", "pre", string.format("@%s:lavfi=[split[a][b];[b]%s,nullsink;[a]null]",
                labels.cropdetect, cropdetect))
        end
    end
    if not insert_filter() then
        mp.msg.error("Does vf=help as #1 line in mvp.conf return libavfilter list with crop/cropdetect in log?")
        s.f_missing = true
        cleanup()
        return
    end
    if not s.f_limit_runtime then
        s.f_inserted = true -- skip process and wait for new s.collected
    end
    s.f_limit_change = change -- filter is updated for limit change
end

local function apply_crop(ref, pts)
    -- osd size change
    -- TODO add auto/smart mode
    local prop_fullscreen = mp.get_property("fullscreen")
    if prop_fullscreen ~= "yes" and options.fix_windowed_behavior ~= 0 then
        local prop_maximized = mp.get_property("window-maximized")
        local osd = mp.get_property_native("osd-dimensions")
        local prop_auto_window_resize = mp.get_property("auto-window-resize")
        if prop_auto_window_resize == "yes" and options.fix_windowed_behavior == 1 then
            -- disable auto resize to avoid resizing at the original size of the video
            mp.set_property("auto-window-resize", "no")
        end
        if prop_maximized ~= "yes" then
            if options.fix_windowed_behavior == 2 then
                mp.set_property("geometry", string.format("%s", osd.w))
            elseif options.fix_windowed_behavior == 3 then
                mp.set_property("geometry", string.format("x%s", osd.h))
            end
        end
    end

    -- crop filter insertion/update
    if s.f_video_crop then
        mp.set_property("video-crop", string.format("%sx%s+%s+%s", ref.w, ref.h, ref.x, ref.y))
    elseif filter_state(labels.crop) and not s.seeking then
        for _, axis in ipairs({"w", "x", "h", "y"}) do -- "w""x" then "h""y" to reduce visual glitch
            if s.applied[axis] ~= ref[axis] then command_filter(labels.crop, axis, ref[axis], "crop") end
        end
    else
        mp.commandv("vf", "append", string.format("@%s:lavfi-crop=%s", labels.crop, ref.whxy))
    end
    ref.applied = ref.applied + 1
    s.applied = ref

    print_debug("pre_format", string.format("- Apply: %s", ref.whxy))
    if options.debug and pts then table.insert(s.stats.indexed_applied, {ref = ref, pts = pts}) end
end

local function compute_metadata(meta)
    meta.whxy = string.format("w=%s:h=%s:x=%s:y=%s", meta.w, meta.h, meta.x, meta.y)
    meta.offset = {x = meta.x - (s.source.w - meta.w) / 2, y = meta.y - (s.source.h - meta.h) / 2}
    meta.mt = meta.y
    meta.mb = s.source.h - meta.h - meta.y
    meta.ml = meta.x
    meta.mr = s.source.w - meta.w - meta.x
    meta.is_source = meta.whxy == s.source.whxy
    meta.is_invalid = meta.h < 0 or meta.w < 0
    meta.is_trusted_offsets = is_trusted_offset(meta.offset.x, "x") and is_trusted_offset(meta.offset.y, "y")
    meta.time = {buffer = 0, overall = 0}
    if options.read_ahead_mode > 0 then meta.pts = {} end
    local margin = options.detect_round * options.linked_tolerance
    meta.is_linked_to_source = meta.mt <= margin and meta.mb <= margin or meta.ml <= margin and meta.mr <= margin
    if meta.is_linked_to_source and not meta.is_invalid and s.ratios.w[meta.w] or s.ratios.h[meta.h] then
        meta.is_known_ratio = true
    end
    return meta
end

local function generate_ratios(list)
    for ratio in string.gmatch(list, "%S+%s?") do
        for a, b in string.gmatch(tostring(ratio), "(%d+)/(%d+)") do ratio = a / b end
        local w, h = math.floor((s.source.h * ratio)), math.floor((s.source.w / ratio))
        local margin = options.ratio_tolerance
        for k, v in pairs({w = w, h = h}) do
            if v < s.source[k] - options.linked_tolerance then
                if v % 2 == 1 then
                    s.ratios[k][v + 1], s.ratios[k][v - 1] = true, true
                    if margin > 0 then
                        s.ratios[k][v + 1 + margin], s.ratios[k][v - 1 - margin] = true, true
                    end
                else
                    s.ratios[k][v] = true
                    if margin > 0 then s.ratios[k][v + margin], s.ratios[k][v - margin] = true, true end
                end
            end
        end
    end
end

local function switch_hwdec(id, hwdec, error)
    if hwdec ~= "no" and not string.match(hwdec, "-copy") then
        local msg = "Switch to SW decoding or HW -copy variant."
        mp.msg.info(msg)
        mp.osd_message(string.format("%s: %s", label_prefix, msg), 5)
    end
    if s.hwdec and hwdec ~= s.hwdec and s.hwdec ~= "no" and not string.match(s.hwdec, "-copy") and
        filter_state(labels.cropdetect) then mp.commandv("vf", "remove", string.format("@%s", labels.cropdetect)) end
    s.hwdec = hwdec
end

local function process_metadata(collected, timestamp, elapsed_time)
    s.in_progress = true -- prevent event race
    print_debug("metadata", collected, "Collected")

    local function cleanup_stat(whxy, ref, ref_i, index)
        if ref[whxy] then
            ref[whxy] = nil
            ref_i[index] = ref_i[index] - 1
        end
    end

    -- buffer: init
    if not s.stats.buffer[collected.whxy] then
        s.stats.buffer[collected.whxy] = collected
        s.stats.buffer_unique = s.stats.buffer_unique + 1
    end

    -- buffer: add collected or increase it's timer
    if s.buffer.i_total == 0 or s.buffer.indexed_list[s.buffer.i_total].ref ~= collected then
        s.buffer.i_total = s.buffer.i_total + 1
        s.buffer.i_ratio = s.buffer.i_ratio + 1
        s.buffer.indexed_list[s.buffer.i_total] = {ref = collected, pts = timestamp, t_elapsed = elapsed_time}
        if options.read_ahead_mode > 0 then table.insert(collected.pts, timestamp) end
    elseif s.last_collected == collected then
        s.buffer.indexed_list[s.buffer.i_total].t_elapsed = s.buffer.indexed_list[s.buffer.i_total].t_elapsed +
                                                                elapsed_time
    end
    collected.time.overall = collected.time.overall + elapsed_time
    collected.time.buffer = collected.time.buffer + elapsed_time
    s.buffer.t_total = s.buffer.t_total + elapsed_time
    if s.buffer.i_ratio > 0 then s.buffer.t_ratio = s.buffer.t_ratio + elapsed_time end

    -- candidate offset/fallback to later extend buffer size
    if not s.stats.trusted[collected.whxy] and collected.time.buffer > options.ratio_timer and
        collected.is_linked_to_source then
        if not s.candidate.offset[collected.whxy] and not collected.is_trusted_offsets and collected.is_known_ratio then
            s.candidate.offset[collected.whxy] = collected
            s.candidate.i_offset = s.candidate.i_offset + 1
        elseif not collected.is_known_ratio and not s.candidate.offset[collected.whxy] and
            not s.candidate.fallback[collected.whxy] then
            s.candidate.fallback[collected.whxy] = collected
            s.candidate.i_fallback = s.candidate.i_fallback + 1
        end
    end

    -- add new fallback ratio to the ratio list
    if s.candidate.fallback[collected.whxy] and collected.time.buffer >= options.fallback_timer then
        -- TODO eventually re-check the buffer list with new ratio
        generate_ratios(collected.w .. "/" .. collected.h)
        collected.is_known_ratio = true
        cleanup_stat(collected.whxy, s.candidate.fallback, s.candidate, "i_fallback")
    end

    -- add new offset to the trusted_offsets list
    if s.candidate.offset[collected.whxy] and collected.is_known_ratio and collected.is_linked_to_source and
        collected.time.buffer >= options.offset_timer then
        for _, axis in ipairs({"x", "y"}) do
            if not is_trusted_offset(collected.offset[axis], axis) then
                table.insert(s.stats.trusted_offset[axis], collected.offset[axis])
            end
        end
        cleanup_stat(collected.whxy, s.candidate.offset, s.candidate, "i_offset")
        collected.is_trusted_offsets = true
    end

    -- add collected ready to the trusted list
    local new_ready =
        not s.stats.trusted[collected.whxy] and collected.is_trusted_offsets and not collected.is_invalid and
            collected.is_linked_to_source and collected.is_known_ratio and collected.time.buffer >= options.ratio_timer
    if new_ready then
        s.stats.trusted[collected.whxy] = collected
        s.stats.trusted_unique = s.stats.trusted_unique + 1
        collected.applied = 0
        collected.time.accumulated = collected.time.buffer
    end

    -- use current as main metadata, override by corrected or stabilized if needed
    local current = collected

    -- correction with trusted metadata for fast change in dark/ambiguous scene
    local corrected = {}
    if not current.is_invalid and s.stats.trusted_unique > 1 and not s.stats.trusted[current.whxy] then
        -- is_bigger than applied meta
        corrected.is_bigger = current.mt < s.approved.mt or current.mb < s.approved.mb or current.ml < s.approved.ml or
                                  current.mr < s.approved.mr
        -- find closest trusted metadata
        local closest = {}
        local margin = options.detect_round * options.linked_tolerance
        for _, ref in pairs(s.stats.trusted) do
            local diff = {ref = ref, vs_current = 0, vs_applied = 0, total = 0}
            for _, side in ipairs({"mt", "mb", "ml", "mr"}) do
                diff[side] = current[side] - ref[side]
                diff.total = diff.total + math.abs(diff[side])
                if diff[side] > margin or diff[side] < -margin then diff.vs_current = diff.vs_current + 1 end
                if ref[side] ~= s.approved[side] then diff.vs_applied = diff.vs_applied + 1 end
            end
            -- is_inside this trusted meta with tiny tolerance for being outside
            diff.is_inside = not (diff.mt < -margin or diff.mb < -margin or diff.ml < -margin or diff.mr < -margin)
            local pattern = diff.is_inside and
                                (diff.vs_current <= 1 or diff.vs_current == 2 and diff.vs_applied <= 2 or
                                    diff.vs_current > 2 and corrected.is_bigger)
            local set = closest.ref and
                            (diff.vs_current < closest.vs_current or diff.vs_current == closest.vs_current and
                                diff.vs_applied < closest.vs_applied or diff.vs_current == closest.vs_current and
                                diff.vs_applied == closest.vs_applied and diff.total < closest.total)
            -- mp.msg.info(string.format("\\ %-5s %-29s curr:%s appl:%s | %-3s %-3s %-3s %-3s %-4s | is_in:%s ",
            --     pattern and (not closest.ref or set), ref.whxy, diff.vs_current, diff.vs_applied, diff.mt, diff.mb,
            --     diff.ml, diff.mr, diff.total, diff.is_inside))
            if pattern and (not closest.ref or set) then closest = diff end
        end
        -- replace current with corrected
        if closest.ref then
            current = closest.ref
            corrected.ref = closest.ref
            s.buffer.indexed_list[s.buffer.i_total].new_ref = current
            print_debug("metadata", current, "\\ Corrected")
        else
            print_debug("pre_format", "\\ Uncorrected")
        end
    end

    -- stabilization of odd/unstable meta
    local stabilized
    if options.detect_round <= 4 and s.stats.trusted[current.whxy] then
        local margin = options.detect_round * 4
        local applied_in_margin = math.abs(current.w - s.approved.w) <= margin and math.abs(current.h - s.approved.h) <=
                                      margin
        for _, ref in pairs(s.stats.trusted) do
            local in_margin = math.abs(current.w - ref.w) <= margin and math.abs(current.h - ref.h) <= margin
            if in_margin then
                local gt_applied = applied_in_margin and ref ~= s.approved and ref.time.overall >
                                       s.approved.time.overall * 2
                local applied_gt = applied_in_margin and ref == s.approved and ref.time.overall * 2 >
                                       current.time.overall
                local pattern = not applied_in_margin and ref.time.overall > current.time.overall or gt_applied or
                                    applied_gt
                local set = stabilized and ref.time.overall > stabilized.time.overall
                -- mp.msg.info("\\", ref.whxy, ref.time.overall, current.time.overall, s.approved.time.overall)
                if ref ~= current and pattern and (not stabilized or set) then stabilized = ref end
            end
        end
        if stabilized then
            current = stabilized
            s.buffer.indexed_list[s.buffer.i_total].new_ref = current
            print_debug("metadata", current, "\\ Stabilized")
        end
    end

    -- cycle time.accumulated for fast_change_timer (reset if uncorrected)
    for whxy, ref in pairs(s.stats.trusted) do
        ref.time.accumulated = whxy ~= current.whxy and 0 or ref.time.accumulated < 0 and 0 + elapsed_time or
                                   not new_ready and ref.time.accumulated + elapsed_time or ref.time.accumulated
    end

    -- crop: final validation then store or apply it
    local detect_source = current == s.last_current and (current.is_source or collected.is_source) and s.limit.target >=
                              0
    local confirmation = not current.is_source and s.stats.trusted[current.whxy] and current.time.accumulated >=
                             options.fast_change_timer and (not corrected.ref or current == s.last_current)
    local crop_filter = s.approved ~= current and (confirmation or detect_source)
    if crop_filter and (not s.timestamps.prevent or timestamp >= s.timestamps.prevent) then
        s.approved = current -- reflect s.applied for read_head
        if s.limit.current < s.limit.min then
            s.limit.min = s.limit.current -- store minimum limit
        end
        if s.f_limit_runtime and options.read_ahead_mode > 0 then
            local pts = current.time.accumulated < options.ratio_timer and timestamp - current.time.accumulated or
                            current.pts[1]
            table.insert(s.indexed_read_ahead, {ref = current, pts = pts})
            s.timestamps.read_ahead = nil
        else
            apply_crop(current, timestamp)
        end
        if options.prevent_change_mode > 0 then
            s.timestamps.prevent = nil
            if (options.prevent_change_mode == 1 and (current.w > s.approved.w or current.h > s.approved.h) or
                options.prevent_change_mode == 2 and (current.w < s.approved.w or current.h < s.approved.h) or
                options.prevent_change_mode == 3) then
                s.timestamps.prevent = timestamp + options.prevent_change_timer
            end
        end
        if options.mode <= 2 then on_toggle(true) end
    end

    local function is_time_to_cleanup_buffer(time, target_time)
        return time > target_time * (1 + options.segmentation)
    end

    -- buffer: reduce size of known ratio stats
    while is_time_to_cleanup_buffer(s.buffer.t_ratio, options.ratio_timer) do
        local i = (s.buffer.i_total + 1) - s.buffer.i_ratio
        s.buffer.t_ratio = s.buffer.t_ratio - s.buffer.indexed_list[i].t_elapsed
        s.buffer.i_ratio = s.buffer.i_ratio - 1
    end

    -- buffer: check for candidate to extend it
    local buffer_timer = s.candidate.i_offset > 0 and options.offset_timer or s.candidate.i_fallback > 0 and
                             options.fallback_timer or options.ratio_timer

    -- buffer: cleanup fake candidate
    local function is_proactive_cleanup_needed()
        local test
        if is_time_to_cleanup_buffer(s.buffer.t_total, options.ratio_timer) then
            for _, cat in ipairs({"offset", "fallback"}) do
                if s.candidate["i_" .. cat] > 0 then
                    test = true
                    for whxy, ref in pairs(s.candidate[cat]) do
                        if ref.time.buffer > s.buffer.t_total * options.reverse_segmentation then
                            return false -- if at least one is a proper candidate
                        end
                    end
                end
            end
        end
        return test
    end

    -- buffer: reduce total size
    while is_time_to_cleanup_buffer(s.buffer.t_total, buffer_timer) or is_proactive_cleanup_needed() do
        s.buffer.i_to_shift = s.buffer.i_to_shift + 1
        local entry = s.buffer.indexed_list[s.buffer.i_to_shift]
        entry.ref.time.buffer = entry.ref.time.buffer - entry.t_elapsed
        if options.read_ahead_mode > 0 then table.remove(entry.ref.pts, 1) end
        if s.stats.buffer[entry.ref.whxy] and entry.ref.time.buffer == 0 then
            cleanup_stat(entry.ref.whxy, s.stats.buffer, s.stats, "buffer_unique")
            cleanup_stat(entry.ref.whxy, s.candidate.offset, s.candidate, "i_offset")
            cleanup_stat(entry.ref.whxy, s.candidate.fallback, s.candidate, "i_fallback")
        end
        s.buffer.t_total = s.buffer.t_total - entry.t_elapsed
    end

    -- buffer: shift the list to overwrite unused data
    if s.buffer.i_to_shift >= 20 or s.buffer.i_to_shift == s.buffer.i_total then
        for i = s.buffer.i_to_shift + 1, s.buffer.i_total do
            s.buffer.indexed_list[i - s.buffer.i_to_shift] = s.buffer.indexed_list[i]
        end
        for i = 0, s.buffer.i_to_shift - 1 do s.buffer.indexed_list[s.buffer.i_total - i] = nil end
        s.buffer.i_total = s.buffer.i_total - s.buffer.i_to_shift
        s.buffer.i_to_shift = 0
        collectgarbage("step")
    end

    -- limit: automatic adjustment
    s.last_limit = s.limit.current
    if s.f_limit_runtime or timestamp >= s.limit.timer then
        s.limit.last_target = s.limit.target
        if collected.is_source or current.is_source or corrected.is_bigger then
            -- increase limit
            s.limit.target = 1
            if s.limit.current + s.limit.step * s.limit.up <= options.detect_limit then
                s.limit.current = s.limit.current + s.limit.step * s.limit.up
            else
                s.limit.current = options.detect_limit
            end
        elseif not current.is_invalid and
            (collected.is_trusted_offsets or collected == s.last_collected or current == s.last_current) then
            -- stable limit
            s.limit.target = 0
            -- reset limit to help with different dark color
            if not current.is_trusted_offsets then s.limit.current = options.detect_limit end
        elseif s.limit.current > 0 then
            -- decrease limit
            s.limit.target = -1
            if s.limit.min < s.limit.current and s.limit.last_target == -1 then
                s.limit.current = s.limit.min
            elseif s.limit.current - s.limit.step >= 0 then
                s.limit.current = s.limit.current - s.limit.step
            else
                s.limit.current = 0
            end
        end
    end

    -- store for next process
    s.last_current = current
    s.last_collected = collected
    s.last_timestamp = timestamp

    -- limit: apply change
    if s.last_limit ~= s.limit.current then
        if not s.f_limit_runtime and options.limit_timer > 0 then s.limit.timer = timestamp + options.limit_timer end
        s.limit.counter = s.limit.counter + 1
        insert_cropdetect_filter(s.limit.current, true)
    end

    s.in_progress = false
end

local function time_pos(event, value, err)
    if value and s.indexed_read_ahead[1] then
        local time_pos = shifting_to(RIGHT, value)
        local deviation = math.abs(time_pos - s.pts)
        local crop_sync = s.frametime * (options.read_ahead_sync + options.crop_method_sync)
        local time_pos_read_ahead = time_pos - (options.read_ahead_timer - deviation - crop_sync)
        if time_pos_read_ahead >= s.indexed_read_ahead[1].pts then
            apply_crop(s.indexed_read_ahead[1].ref, s.indexed_read_ahead[1].pts)
            table.remove(s.indexed_read_ahead, 1)
        end
    end
end

local function collect_metadata(event)
    if event.prefix == "ffmpeg" and event.level == "v" and string.find(event.text, "^.*dyn_cd: ") and
        not (s.seeking or s.paused or s.toggled > 1) then
        local tmp = {}
        for k, v in string.gmatch(event.text, "(%w+):(%-?%d+%.?%d* )") do tmp[k] = tonumber(v) end
        tmp.whxy = string.format("w=%d:h=%d:x=%d:y=%d", tmp.w, tmp.h, tmp.x, tmp.y)
        s.pts = shifting_to(LEFT, tmp.pts)
        if tmp.whxy ~= s.collected.whxy then
            s.collected = s.stats.trusted[tmp.whxy] or s.stats.buffer[tmp.whxy] or compute_metadata(tmp)
        end

        s.limit.last_collect = s.limit.collect
        s.limit.collect = tmp.limit or s.limit.collect
        s.f_limit_runtime = tmp.limit ~= nil -- if ffmpeg is patch for limit change at runtime

        s.timestamps.previous = s.timestamps.current
        s.timestamps.current = s.pts

        local wait_limit = s.f_limit_runtime and s.f_limit_change and s.limit.collect == s.limit.last_collect
        if not wait_limit then s.f_limit_change = false end

        if s.in_progress or not s.timestamps.previous or wait_limit or s.f_inserted or s.timestamps.current <
            options.start_delay then
            s.f_inserted = false
            return
        end

        local elapsed_time = s.timestamps.current - s.timestamps.previous
        if not s.frametime or elapsed_time < s.frametime and elapsed_time > 0 then s.frametime = elapsed_time end

        process_metadata(s.collected, s.timestamps.current, elapsed_time)
    end
end

local function seek(event)
    if s.seek_done then return end
    print_debug("pre_format", string.format("Stop by %s event.", event))
    if event == "seek" or event == "toggle" then
        s.timestamps = {}
        s.limit.timer = 0
        s.approved = s.applied -- re-sync
        if event == "seek" then
            if s.f_limit_runtime then insert_cropdetect_filter(s.limit.current) end
            if not s.f_video_crop and
                (filter_state(labels.crop, "enabled", true) or not filter_state(labels.crop) and s.applied ~= s.source) then
                apply_crop(s.applied)
            end
        end
        if s.f_limit_runtime then
            s.indexed_read_ahead = {}
            s.collected = {}
        end
        s.seek_done = true -- avoid seek() in loop until we resume()
    end
end

local function resume(event)
    s.seek_done = false
    print_debug("pre_format", string.format("Resume by %s event.", event))
    if event == "toggle" and s.f_limit_runtime or not filter_state(labels.cropdetect) then
        insert_cropdetect_filter(s.limit.current)
    end
end

local function playback_events(t, id, error)
    if t.event == "seek" then
        s.seeking = true
        seek(t.event)
    else
        if not s.paused then resume(t.event) end
        s.seeking = false
    end
end

local ENABLE, DISABLE_WITH_CROP, DISABLE = 1, 2, 3
function on_toggle(auto)
    if s.f_missing then
        mp.osd_message("Libavfilter cropdetect missing", 3)
        return
    end
    local EVENT = "toggle"
    if s.toggled == ENABLE then
        s.toggled = DISABLE_WITH_CROP
        if filter_state(labels.cropdetect, "enabled", true) then
            mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect))
        end
        seek(EVENT)
        if not auto then mp.osd_message(string.format("%s: disabled, crop remains.", label_prefix), 3) end
    elseif s.toggled == DISABLE_WITH_CROP then
        s.toggled = DISABLE
        if filter_state(labels.cropdetect, "enabled", false) then
            if s.f_video_crop then
                mp.set_property("video-crop", "")
            elseif filter_state(labels.crop, "enabled", true) then
                mp.commandv("vf", EVENT, string.format("@%s", labels.crop))
            end
        end
        if not auto then mp.osd_message(string.format("%s: crop removed.", label_prefix), 3) end
    else -- s.toggled == DISABLE
        s.toggled = ENABLE
        if filter_state(labels.cropdetect, "enabled", false) then
            mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect))
        end
        if s.f_video_crop then
            apply_crop(s.applied)
        elseif filter_state(labels.crop, "enabled", false) then
            mp.commandv("vf", EVENT, string.format("@%s", labels.crop))
        end
        resume(EVENT)
        if not auto then mp.osd_message(string.format("%s: enabled.", label_prefix), 3) end
    end
end

local function pause(event, is_paused)
    s.paused = is_paused
    if is_paused then
        seek(event)
        print_stats()
        print_debug("buffer")
        print_debug("applied")
        print_debug("pre_format", "s.approved: " .. s.approved.whxy)
        print_debug("pre_format", "s.applied: " .. s.applied.whxy)
        if s.indexed_read_ahead[1] then
            print_debug("pre_format", "s.indexed_read_ahead[1]: " .. s.indexed_read_ahead[1].ref.whxy)
        end
    else
        if s.toggled == 1 then resume(event) end
    end
end

function cleanup()
    if not s.started then return end
    if not s.paused then print_stats() end
    mp.msg.info("Cleanup...")
    mp.set_property("auto-window-resize", s.user_auto_window_resize)
    mp.unregister_event(playback_events)
    mp.unregister_event(collect_metadata)
    mp.unobserve_property(time_pos)
    mp.unobserve_property(switch_hwdec)
    mp.unobserve_property(pause)
    for _, label in pairs(labels) do
        if filter_state(label) then mp.commandv("vf", "remove", string.format("@%s", label)) end
    end
    if s.f_video_crop then mp.set_property("video-crop", "") end
    mp.msg.info("Done.")
    s.started = false
end

local function on_start()
    mp.msg.info("File loaded.")
    if not is_cropable() then
        mp.msg.warn("Exit, only works for videos.")
        return
    end
    s.user_geometry = mp.get_property("geometry")
    s.user_auto_window_resize = mp.get_property("auto-window-resize")
    if options.fix_windowed_behavior == 1 and s.user_auto_window_resize == "yes" then
        mp.set_property("auto-window-resize", "no")
    end
    -- init/re-init stored data
    s.buffer = {i_to_shift = 0, i_total = 0, i_ratio = 0, indexed_list = {}, t_total = 0, t_ratio = 0}
    s.candidate = {i_fallback = 0, i_offset = 0, fallback = {}, offset = {}}
    s.collected = {}
    s.indexed_read_ahead = {}
    s.limit = {
        counter = 0, current = options.detect_limit, min = options.detect_limit, step = 2, target = 0, timer = 0, up = 2
    }
    s.stats = {applied = {}, buffer = {}, buffer_unique = 0, trusted = {}, trusted_offset = {}, trusted_unique = 1}
    s.stats.indexed_applied = {}
    s.source = {w_untouched = mp.get_property_number("width"), h_untouched = mp.get_property_number("height")}
    s.source.w = math.floor(s.source.w_untouched / options.detect_round) * options.detect_round
    s.source.h = math.floor(s.source.h_untouched / options.detect_round) * options.detect_round
    s.source.x = math.floor((s.source.w_untouched - s.source.w) / 2)
    s.source.y = math.floor((s.source.h_untouched - s.source.h) / 2)
    s.stats.trusted_offset = {x = {s.source.x}, y = {s.source.y}}
    s.ratios = {w = {}, h = {}}
    generate_ratios(options.ratios)
    s.source = compute_metadata(s.source)
    s.stats.trusted[s.source.whxy] = s.source
    s.source.applied = 1
    s.source.time.accumulated = 0
    s.applied = s.source
    s.approved = s.source
    s.timestamps = {}
    if options.read_ahead_mode > 0 then
        -- assume cropdetect is patch for command "limit", fallback at the first collected metadata otherwise.
        s.f_limit_runtime = true
        -- quick test for dummysync filter
        s.f_sync = mp.commandv("vf", "add", string.format("@%s:lavfi=[split[a][b];[a][b]dummysync]", label_prefix)) and
                       mp.commandv("vf", "remove", string.format("@%s", label_prefix)) and "[a][b]dummysync" or
                       "[a][b]psnr=eof_action=pass"
    end
    s.f_video_crop = options.crop_method == 1 and mp.get_property("video-crop") ~= nil -- true if supported
    -- register events
    mp.register_event("seek", playback_events)
    mp.register_event("playback-restart", playback_events)
    mp.observe_property("time-pos", "number", time_pos)
    mp.observe_property("hwdec", "string", switch_hwdec)
    mp.observe_property("pause", "bool", pause)
    mp.enable_messages('v')
    mp.register_event("log-message", collect_metadata)
    s.toggled = (options.mode % 2 == 1) and DISABLE or ENABLE
    s.started = true -- everything ready
end

mp.add_key_binding("C", "toggle_crop", on_toggle)
mp.register_event("end-file", cleanup)
mp.register_event("file-loaded", on_start)
