--lite version of the code written by sorayuki
--only keep the function to record the histroy and recover it

local mp = require 'mp'
local utils = require 'mp.utils'
local options = require 'mp.options'
local msg = require 'mp.msg' -- this is for debugging

local o = {
    enabled = true,
    -- eng=English, chs=Chinese Simplified
    language = 'eng',
    timeout = 15,
    save_period = 30,
    -- Set '/:dir%mpvconf%/historybookmarks' to use mpv config directory
    -- OR change to '/:dir%script%/historybookmarks' for placing it in the same directory of script
    -- OR change to '~~/historybookmarks' for sub path of mpv portable_config directory
    -- OR write any variable using '/:var', such as: '/:var%APPDATA%/mpv/historybookmarks' or '/:var%HOME%/mpv/historybookmarks'
    -- OR specify the absolute path
    history_dir = "/:dir%mpvconf%/historybookmarks",
    -- specifies the extension of the history-bookmark file
    bookmark_ext = ".mpv.history",
    -- use hash to bookmark_name
    hash = true,
    -- set false to get playlist from directory
    use_playlist = true,
    -- specifies a whitelist of files to find in a directory
    whitelist = "3gp,amr,amv,asf,avi,avi,bdmv,f4v,flv,m2ts,m4v,mkv,mov,mp4,mpeg,mpg,ogv,rm,rmvb,ts,vob,webm,wmv",
    -- excluded directories for shared, #windows: ["X:", "Z:", "F:/Download/", "Download"]
    excluded_dir = [[
        []
        ]],
    included_dir = [[
    []
    ]]
}
options.read_options(o, _, function() end)

o.excluded_dir = utils.parse_json(o.excluded_dir)
o.included_dir = utils.parse_json(o.included_dir)

local file_loaded = false

local locals = {
    ['eng'] = {
        msg1 = 'Resume successfully',
        msg2 = 'Resume the last played file in current directory',
        msg3 = 'Press 1 to confirm, 0 to cancel',
    },
    ['chs'] = {
        msg1 = '成功恢复上次播放',
        msg2 = '是否恢复当前目录的上次播放文件',
        msg3 = '按1确认，按0取消',
    }
}

-- apply lang opts
local texts = locals[o.language]

-- `pl` stands for playlist
local path = nil
local dir = nil
local fname = nil
local pl_count = 0
local pl_name = nil
local pl_path = nil
local pl_list = {}
local pl_idx = 1
local current_idx = 1
local bookmark_path = nil
local history_dir = nil
local normalize_path = nil

local wait_msg
local on_key = false

if o.history_dir:find('^/:dir%%mpvconf%%') then
    history_dir = o.history_dir:gsub('/:dir%%mpvconf%%', mp.find_config_file('.'))
elseif o.history_dir:find('^/:dir%%script%%') then
    history_dir = o.history_dir:gsub('/:dir%%script%%', mp.find_config_file('scripts'))
elseif o.history_dir:find('/:var%%(.*)%%') then
    local os_variable = o.history_dir:match('/:var%%(.*)%%')
    history_dir = o.history_dir:gsub('/:var%%(.*)%%', os.getenv(os_variable))
else
    history_dir = mp.command_native({ "expand-path", o.history_dir }) -- Expands both ~ and ~~
end

local is_windows = package.config:sub(1, 1) == "\\" -- detect path separator, detect path separator, windows uses backslashes
--create history_dir if it doesn't exist
if history_dir ~= '' then
    local meta = utils.file_info(history_dir)
    if not meta or not meta.is_dir then
        local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", history_dir) }
        local unix_args = { 'mkdir', '-p', history_dir }
        local args = is_windows and windows_args or unix_args
        local res = mp.command_native({ name = "subprocess", capture_stdout = true, playback_only = false, args = args })
        if res.status ~= 0 then
            msg.error("Failed to create history_dir save directory " .. history_dir ..
            ". Error: " .. (res.error or "unknown"))
            return
        end
    end
end

local function split(input)
    local ret = {}
    for str in string.gmatch(input, "([^,]+)") do
        ret[#ret + 1] = str
    end
    return ret
end

local ext_whitelist = split(o.whitelist)

local function exclude(extension)
    if #ext_whitelist > 0 then
        for _, ext in pairs(ext_whitelist) do
            if extension == ext then
                return true
            end
        end
    else
        return
    end
end

local function is_protocol(path)
    return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil)
end

local function need_ignore(tab, val)
    for _, element in pairs(tab) do
        if string.find(val, element) then
            return true
        end
    end
    return false
end

local function tablelength(tab)
    local count = 0
    for _, _ in pairs(tab) do
        count = count + 1
    end
    return count
end

local message_overlay = mp.create_osd_overlay('ass-events')
local message_timer = mp.add_timeout(1, function ()
    message_overlay:remove()
end, true)

function show_message(text, time)
    message_timer:kill()
    message_timer.timeout = time or 1
    message_overlay.data = text
    message_overlay:update()
    message_timer:resume()
end

local function normalize(path)
    if normalize_path ~= nil then
        if normalize_path then
            path = mp.command_native({"normalize-path", path})
        else
            local directory = mp.get_property("working-directory", "")
            path = utils.join_path(directory, path:gsub('^%.[\\/]',''))
            if is_windows then path = path:gsub("\\", "/") end
        end
        return path
    end

    normalize_path = false

    local commands = mp.get_property_native("command-list", {})
    for _, command in ipairs(commands) do
        if command.name == "normalize-path" then
            normalize_path = true
            break
        end
    end
    return normalize(path)
end

function refresh_globals()
    path = mp.get_property("path")
    fname = mp.get_property("filename")
    pl_count = mp.get_property_number('playlist-count', 0)
    if path and not is_protocol(path) then
        path = normalize(path)
        dir = utils.split_path(path)
    else
        dir = nil
    end
end

-- for unix use only
-- returns a table of command path and varargs, or nil if command was not found
local function command_exists(command, ...)
    msg.debug("looking for command:", command)
    -- msg.debug("args:", )
    local process = mp.command_native({
        name = "subprocess",
        capture_stdout = true,
        capture_stderr = true,
        playback_only = false,
        args = {"sh", "-c", "command -v -- " .. command}
    })

    if process.status == 0 then
        local command_path = process.stdout:gsub("\n", "")
        msg.debug("command found:", command_path)
        return {command_path, ...}
    else
        msg.debug("command not found:", command)
        return nil
    end
end

-- returns md5 hash of the full path of the current media file
local function hash(path)
    if path == nil then
        msg.debug("something is wrong with the path, can't get full_path, can't hash it")
        return
    end

    msg.debug("hashing:", path)

    local cmd = {
        name = 'subprocess',
        capture_stdout = true,
        playback_only = false,
    }

    local args = nil
    local is_unix = package.config:sub(1,1) == "/"
    if is_unix then
        local md5 = command_exists("md5sum") or command_exists("md5") or command_exists("openssl", "md5 | cut -d ' ' -f 2")
        if md5 == nil then
            msg.warn("no md5 command found, can't generate hash")
            return
        end
        md5 = table.concat(md5, " ")
        cmd["stdin_data"] = path
        args = {"sh", "-c", md5 .. " | cut -d ' ' -f 1 | tr '[:lower:]' '[:upper:]'" }
    else --windows
        -- https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/get-filehash?view=powershell-7.3
        local hash_command = [[
            $s = [System.IO.MemoryStream]::new();
            $w = [System.IO.StreamWriter]::new($s);
            $w.write(']] .. path .. [[');
            $w.Flush();
            $s.Position = 0;
            Get-FileHash -Algorithm MD5 -InputStream $s | Select-Object -ExpandProperty Hash
        ]]

        args = {"powershell", "-NoProfile", "-Command", hash_command}
    end
    cmd["args"] = args
    msg.debug("hash cmd:", utils.to_string(cmd))
    local process = mp.command_native(cmd)

    if process.status == 0 then
        local hash = process.stdout:gsub("%s+", "")
        msg.debug("hash:", hash)
        return hash
    else
        msg.warn("hash function failed")
        return
    end
end

local function get_bookmark_path(dir)
    local fpath = string.sub(dir, 1, -2)
    local _, name = utils.split_path(fpath)
    local history_name = nil
    if o.hash then
        history_name = hash(dir)
        if history_name == nil then
            msg.warn("hash function failed, fallback to dirname")
            history_name = name
        end
    else
        history_name = name
    end
    local bookmark_name = history_name .. o.bookmark_ext
    bookmark_path = utils.join_path(history_dir, bookmark_name)
    if is_windows then bookmark_path = bookmark_path:gsub("\\", "/") end
end

local function file_exist(path)
    local meta = utils.file_info(path)
    if not meta or not meta.is_file then
        return false
    end
    return true
end

-- get the content of the bookmark
-- Arg: bookmark_file (path)
-- Return: nil / content of the bookmark
local function get_record(bookmark_path)
    local file = io.open(bookmark_path, 'r')
    local record = file:read()
    if record == nil then
        msg.verbose('No history record is found in the bookmark file.')
        return nil
    end
    msg.verbose('last play: ' .. record)
    file:close()
    return record
end

----- winapi start -----
-- in windows system, we can use the sorting function provided by the win32 API
-- see https://learn.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-strcmplogicalw
-- this function was taken from https://github.com/mpvnet-player/mpv.net/issues/575#issuecomment-1817413401
local winapi = {}
local is_windows = mp.get_property_native("platform") == "windows"

if is_windows then
    -- is_ffi_loaded is false usually means the mpv builds without luajit
    local is_ffi_loaded, ffi = pcall(require, "ffi")

    if is_ffi_loaded then
        winapi = {
            ffi = ffi,
            C = ffi.C,
            CP_UTF8 = 65001,
            shlwapi = ffi.load("shlwapi"),
        }

        -- ffi code from https://github.com/po5/thumbfast, Mozilla Public License Version 2.0
        ffi.cdef[[
            int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr,
            int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
            int __stdcall StrCmpLogicalW(wchar_t *psz1, wchar_t *psz2);
        ]]

        winapi.utf8_to_wide = function(utf8_str)
            if utf8_str then
                local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, nil, 0)

                if utf16_len > 0 then
                    local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len)

                    if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, utf8_str, -1, utf16_str, utf16_len) > 0 then
                        return utf16_str
                    end
                end
            end

            return ""
        end
    end
end
----- winapi end -----

local function alphanumsort_windows(filenames)
    table.sort(filenames, function(a, b)
        local a_wide = winapi.utf8_to_wide(a)
        local b_wide = winapi.utf8_to_wide(b)
        return winapi.shlwapi.StrCmpLogicalW(a_wide, b_wide) == -1
    end)

    return filenames
end

-- alphanum sorting for humans in Lua
-- http://notebook.kulchenko.com/algorithms/alphanumeric-natural-sorting-for-humans-in-lua
local function alphanumsort_lua(filenames)
    local function padnum(n, d)
        return #d > 0 and ("%03d%s%.12f"):format(#n, n, tonumber(d) / (10 ^ #d))
            or ("%03d%s"):format(#n, n)
    end

    local tuples = {}
    for i, f in ipairs(filenames) do
        tuples[i] = {f:lower():gsub("0*(%d+)%.?(%d*)", padnum), f}
    end
    table.sort(tuples, function(a, b)
        return a[1] == b[1] and #b[2] < #a[2] or a[1] < b[1]
    end)
    for i, tuple in ipairs(tuples) do filenames[i] = tuple[2] end
    return filenames
end

local function alphanumsort(filenames)
    local is_ffi_loaded = pcall(require, "ffi")
    if is_windows and is_ffi_loaded then
        alphanumsort_windows(filenames)
    else
        alphanumsort_lua(filenames)
    end
end

local function create_playlist(dir)
    local pl_list = {}
    local file_list = utils.readdir(dir, 'files')
    for i = 1, #file_list do
        local file = file_list[i]
        local ext = file:match('%.([^./]+)$')
        if ext and exclude(ext:lower()) then
            table.insert(pl_list, file)
            msg.verbose("Adding " .. file)
        end
    end
    alphanumsort(pl_list)
    return pl_list
end

local function get_playlist()
    local pl_list = {}
    local playlist = mp.get_property_native("playlist")
    for i = 0, #playlist - 1 do
        local filename = mp.get_property("playlist/" .. i .. "/filename")
        local _, file = utils.split_path(filename)
        table.insert(pl_list, file)
    end
    return pl_list
end

-- get the index of the wanted file playlist
-- if there is no playlist, return nil
local function get_playlist_idx(dst_file)
    if dst_file == nil or dst_file == " " then
        return nil
    end

    local idx = nil
    for i = 1, #pl_list do
        if (dst_file == pl_list[i]) then
            idx = i
            return idx
        end
    end
    return idx
end

local function jump_resume()
    mp.unregister_event(jump_resume)
    show_message(texts.msg1, 2)
end

local function unbind_key()
    msg.verbose('Unbinding keys')
    wait_jump_timer:kill()
    mp.remove_key_binding('key_jump')
    mp.remove_key_binding('key_cancel')
end

local function key_jump()
    on_key = true
    wait_jump_timer:kill()
    unbind_key()
    current_idx = pl_idx
    mp.register_event('file-loaded', jump_resume)
    msg.verbose('Jumping to ' .. pl_path)
    mp.commandv('loadfile', pl_path)
end

local function key_cancel()
    on_key = true
    wait_jump_timer:kill()
    unbind_key()
end

local function bind_key()
    mp.add_forced_key_binding('1', 'key_jump', key_jump)
    mp.add_forced_key_binding('0', 'key_cancel', key_cancel)
end

-- creat a .history file
local function record_history()
    if not o.enabled or not file_loaded then return end
    refresh_globals()
    if not path or is_protocol(path) then return end
    get_bookmark_path(dir)
    local eof = mp.get_property_bool("eof-reached")
    local percent_pos = mp.get_property_number("percent-pos", 0)
    if not eof and percent_pos < 90 then
        if fname ~= nil then
            local file = io.open(bookmark_path, "w")
            file:write(fname .. "\n")
            file:close()
        end
    else
        local file = io.open(bookmark_path, "w")
        file:write(" " .. "\n")
        file:close()
    end
end

local timeout = o.timeout
local function wait_jumping()
    timeout = timeout - 1
    if timeout > 0 then
        if not on_key then
            local msg = string.format("%s -- %s? (%s) %02d", wait_msg, texts.msg2, texts.msg3, timeout)
            show_message(msg, 1)
            bind_key()
        else
            timeout = 0
            wait_jump_timer:kill()
            unbind_key()
        end
    else
        wait_jump_timer:kill()
        unbind_key()
    end
end

-- record the file name when video is paused
-- and stop the timer
local function pause(_, paused)
    if paused then
        timer4saving_history:stop()
        record_history()
    else
        timer4saving_history:resume()
    end
end

-- main function of the file
local function record()
    if not o.enabled then return end
    refresh_globals()
    if pl_count and pl_count < 1 then return end
    if not path or is_protocol(path) or not file_exist(path) then return end
    if not dir or not fname then return end
    get_bookmark_path(dir)
    included_dir_count = tablelength(o.included_dir)
    if included_dir_count > 0 then
        if not need_ignore(o.included_dir, dir) then return end
    end
    if need_ignore(o.excluded_dir, dir) then return end

    msg.verbose('folder -- ' .. dir)
    msg.verbose('playing -- ' .. fname)
    msg.verbose('bookmark path -- ' .. bookmark_path)

    if (not file_exist(bookmark_path)) then
        pl_name = nil
        return
    else
        pl_name = get_record(bookmark_path)
        if pl_name then
            pl_path = utils.join_path(dir, pl_name)
        else
            pl_name = fname
            pl_path = path
        end
    end

    if o.use_playlist or pl_count > 1 then
        pl_list = get_playlist()
    else
        pl_list = create_playlist(dir)
    end

    pl_idx = get_playlist_idx(pl_name)
    if (pl_idx == nil) then
        msg.verbose('Playlist not found. Creating a new one...')
    else
        msg.verbose('playlist index --' .. pl_idx)
    end

    current_idx = get_playlist_idx(fname)
    if current_idx then msg.verbose('current index -- ' .. current_idx) end

    if current_idx and (pl_idx == nil) then
        pl_idx = current_idx
        pl_name = fname
        pl_path = path
    elseif current_idx and (pl_idx ~= current_idx) then
        wait_msg = pl_idx
        msg.verbose('Last watched episode -- ' .. wait_msg)
        wait_jump_timer = mp.add_periodic_timer(1, wait_jumping)
    end
    timer4saving_history = mp.add_periodic_timer(o.save_period, record_history)
    mp.observe_property("pause", "bool", pause)
end

mp.register_event('file-loaded', function()
    file_loaded = true
    local path = mp.get_property("path")
    if not is_protocol(path) then
        path = normalize(path)
        directory = utils.split_path(path)
    else
        directory = nil
    end
    if directory ~= nil and directory ~= dir then
        mp.add_timeout(0.5, record)
    end
end)

mp.add_hook("on_unload", 50, function()
    mp.unobserve_property(pause)
    record_history()
    file_loaded = false
end)