--[[
    mpv-sub-select

    This script allows you to configure advanced subtitle track selection based on
    the current audio track and the names and language of the subtitle tracks.

    https://github.com/CogentRedTester/mpv-sub-select
]]--

local mp = require 'mp'
local msg = require 'mp.msg'
local utils = require 'mp.utils'
local opt = require 'mp.options'

local o = {
    --forcibly enable the script regardless of the sid option
    force_enable = false,

    --experimental audio track selection based on the preferences.
    select_audio = false,

    --observe audio switches and reselect the subtitles when alang changes
    observe_audio_switches = false,

    --only select forced subtitles if they are explicitly included in slang
    explicit_forced_subs = false,

    --the folder that contains the 'sub-select.json' file
    config = "~~/script-opts"
}

opt.read_options(o, "sub_select")

local prefs

local ENABLED = o.force_enable or true
local latest_audio = {}
local audio_tracks = {}
local sub_tracks = {}

-- represents when there is no audio or subtitle track selected
local NO_TRACK = {
    id = 0
}

--returns a table that stores the given table t as the __index in its metatable
--creates a prototypally inherited table
local function redirect_table(t, new)
    return setmetatable(new or {}, { __index = t })
end

local function type_check(val, t, required)
    if val == nil then return not required end
    if not t:find(type(val)) then return false end
    return true
end

local function setup_prefs()
    local file = assert(io.open(mp.command_native({"expand-path", o.config .. "/sub-select.json"})))
    local json = file:read("*all")
    file:close()
    prefs = utils.parse_json(json)

    assert(prefs, "Invalid JSON format in sub-select.json.")
    local reservedIDs = { ['^'] = true }
    local IDs = {}

    -- storing the ID in the first pass
    for _, pref in ipairs(prefs) do
        if pref.id then
            assert(not reservedIDs[pref.id], 'using reserved ID '..pref.id)
            assert(not IDs[pref.id], 'duplicate ID '..pref.id)
            IDs[pref.id] = pref
        end
    end

    -- doing a second pass to inherit prefs and type check
    for i, pref in ipairs(prefs) do
        local pref_str = 'pref_'..i..' '..utils.to_string(pref)
        assert(type_check(pref.inherit, 'string'), '`inherit` must be a string: '..pref_str)

        if pref.inherit then
            local parent = pref.inherit == '^' and prefs[i-1] or IDs[pref.inherit]
            assert(parent, 'failed to find matching id: '..pref_str)
            pref = redirect_table(parent, pref)
        end

        -- type checking the options
        assert(type_check(pref.alang, 'string table', true), '`alang` must be a string or a table of strings: '..pref_str)
        assert(type_check(pref.slang, 'string table', true), '`slang` must be a string or a table of strings: '..pref_str)
        assert(type_check(pref.blacklist, 'table'), '`blacklist` must be a table: '..pref_str)
        assert(type_check(pref.whitelist, 'table'), '`whitelist` must be a table: '..pref_str)
        assert(type_check(pref.condition, 'string'), '`condition` must be a string: '..pref_str)
        assert(type_check(pref.id, 'string'), '`id` must be a string: '..pref_str)
    end
end

setup_prefs()

--evaluates and runs the given string in both Lua 5.1 and 5.2
--the name argument is used for error reporting
--provides the mpv modules and the fb module to the string
local function evaluate_string(str, env)
    msg.trace('evaluating string '..str)

    env = redirect_table(_G, env)
    env.mp = redirect_table(mp)
    env.msg = redirect_table(msg)
    env.utils = redirect_table(utils)

    local chunk, err
    if setfenv then
        chunk, err = loadstring(str)
        if chunk then setfenv(chunk, env) end
    else
        chunk, err = load(str, nil, 't', env)
    end
    if not chunk then
        msg.warn('failed to load string:', str)
        msg.error(err)
        chunk = function() return nil end
    end

    local success, boolean = pcall(chunk)
    if not success then msg.error(boolean) end
    return boolean
end

--anticipates the default audio track
--returns the node for the predicted track
--this whole function can be skipped if the user decides to load the subtitles asynchronously instead,
--or if `--aid` is not set to `auto`
local function predict_audio()
    --if the option is not set to auto then it is easy
    local aid = mp.get_property("options/aid", "auto")
    if aid == "no" then return NO_TRACK
    elseif aid ~= "auto" then return audio_tracks[tonumber(aid)] end

    local num_tracks = #audio_tracks
    if num_tracks == 1 then return audio_tracks[1]
    elseif num_tracks == 0 then return NO_TRACK end

    local highest_priority = NO_TRACK
    local priority_str = ""
    local alangs = mp.get_property_native("alang", {})

    --loop through the track list for any audio tracks
    for _,track in ipairs(audio_tracks) do

        --loop through the alang list to check if it has a preference
        local pref = 0
        for j,alang in ipairs(alangs) do
            if track.lang == alang then

                --a lower number j has higher priority, so flip the numbers around so the lowest j has highest preference
                pref = 1000 - j
                break
            end
        end

        --format the important preferences so that we can easily use a lexicographical comparison to find the default
        local formatted_str = string.format("%d-%03d-%d-%02d",
            track.forced and 1 or 0,
            pref,
            track.default and 1 or 0,
            num_tracks - track.id
        )
        msg.trace("formatted track info: " .. formatted_str)

        if formatted_str > priority_str then
            priority_str = formatted_str
            highest_priority = track
        end
    end

    msg.verbose("predicted audio track is "..tostring(highest_priority.id))
    return highest_priority
end

--sets the subtitle track to the given sid
--this is a function to prepare for some upcoming functionality, but I've forgotten what that is
local function set_track(type, id)
    msg.verbose("setting", type, "to", id)
    if mp.get_property_number(type) == id then return end
    mp.set_property('file-local-options/'..type, id)
end

--checks if the given audio matches the given track preference
local function is_valid_audio(audio, pref)
    local alangs = type(pref.alang) == "string" and {pref.alang} or pref.alang

    for _,lang in ipairs(alangs) do
        msg.trace("Checking for valid audio:", lang)

        if audio == NO_TRACK then
            if lang == "no" then return true end
        else
            if lang == '*' then
                return true
            elseif lang == "forced" then
                if audio.forced then return true end
            elseif lang == "default" then
                if audio.default then return true end
            else
                if audio.lang and audio.lang:lower():find(lang) then return true end
            end
        end
    end
    return false
end

--checks if the given sub matches the given track preference
local function is_valid_sub(sub, slang, pref)
    msg.trace("checking sub", slang, "against track", utils.to_string(sub))

    -- Do not try to un-nest these if statements, it will break detection of default and forced tracks.
    -- I've already had to un-nest these statements twice due to this mistake, don't let it happen again.
    if sub == NO_TRACK then
        return slang == 'no'
    else
        if slang == "default" then
            if not sub.default then return false end
        elseif slang == "forced" then
            if not sub.forced then return false end
        else
            if sub.forced and o.explicit_forced_subs then return false end
            if not sub.lang:lower():find(slang) and slang ~= "*" then return false end
        end
    end

    local title = sub.title or ''

    -- if the whitelist is not set then we don't need to find anything
    local passes_whitelist = not pref.whitelist
    local passes_blacklist = true

    -- whitelist/blacklist handling
    if pref.whitelist and title then
        for _,word in ipairs(pref.whitelist) do
            if title:lower():find(word) then passes_whitelist = true end
        end
    end

    if pref.blacklist and title then
        for _,word in ipairs(pref.blacklist) do
            if title:lower():find(word) then passes_blacklist = false end
        end
    end

    msg.trace(string.format("%s %s whitelist: %s | %s blacklist: %s",
        title,
        passes_whitelist and "passed" or "failed", utils.to_string(pref.whitelist),
        passes_blacklist and "passed" or "failed", utils.to_string(pref.blacklist)
    ))
    return passes_whitelist and passes_blacklist
end

--scans the track list and selects audio and subtitle tracks which match the track preferences
--if an audio track is provided to the function it will assume this track is the only audio
local function find_valid_tracks(manual_audio)
    assert(manual_audio == nil or (type(manual_audio) == 'table' and manual_audio.id), 'argument must be an audio track or nil')

    local sub_track_list = {NO_TRACK, unpack(sub_tracks)}
    local audio_track_list

    if manual_audio == nil then
        audio_track_list = {NO_TRACK, unpack(audio_tracks)}
    else
        audio_track_list = {manual_audio}
    end

    if manual_audio then msg.debug("select subtitle for", utils.to_string(manual_audio))
    else msg.debug('selecting audio and subtitles') end

    --searching the selection presets for one that applies to this track
    for _,pref in ipairs(prefs) do
        msg.debug("checking pref:", utils.to_string(pref))

        for _, audio_track in ipairs(audio_track_list) do
            if is_valid_audio(audio_track, pref) then
                local aid = audio_track and audio_track.id

                --checks if any of the subtitle tracks match the preset for the current audio
                local slangs = type(pref.slang) == "string" and {pref.slang} or pref.slang
                msg.trace("valid audio preference found:", utils.to_string(pref.alang))

                for _, slang in ipairs(slangs) do
                    msg.trace("checking for valid sub:", slang)


                    for _,sub_track in ipairs(sub_track_list) do
                        if  is_valid_sub(sub_track, slang, pref)
                            and (not pref.condition or (evaluate_string('return '..pref.condition, {
                                audio = aid > 0 and audio_track or nil,
                                sub = sub_track.id > 0 and sub_track or nil
                            }) == true))
                        then
                            msg.verbose("valid audio preference found:", utils.to_string(pref.alang))
                            msg.verbose("valid subtitle preference found:", utils.to_string(pref.slang))
                            return aid, sub_track and sub_track.id
                        end
                    end
                end
            end
        end
    end
end


--returns the audio node for the currently playing audio track
local function find_current_audio()
    local aid = mp.get_property_number("aid", 0)
    return audio_tracks[aid] or NO_TRACK
end

--extract the language code from an audio track node and pass it to select_subtitles
local function select_tracks(audio)
    local aid, sid = find_valid_tracks(audio)
    if sid then
        set_track('sid', sid == 0 and 'no' or sid)
    end
    if aid and o.select_audio then
        set_track('aid', aid == 0 and 'no' or aid)
    end

    latest_audio = audio or find_current_audio()
end

--select subtitles asynchronously after playback start
local function async_load()
    select_tracks(not o.select_audio and find_current_audio() or nil)
end

--select subtitles synchronously during the on_preloaded hook
local function preload()
    if o.select_audio then return select_tracks() end

    local audio = predict_audio()
    select_tracks(audio)
    latest_audio = audio
end

local track_auto_selection = true
mp.observe_property("track-auto-selection", "bool", function(_,b) track_auto_selection = b end)

local function selection_enabled()
    if not ENABLED then return false end
    if not track_auto_selection then return false end
    if #sub_tracks == 0 then return false end
    return true
end

local INITIAL_LOAD = true
local ORIGINAL_SID = mp.get_property('options/sid')

mp.add_hook('on_load', 50, function()
    INITIAL_LOAD = true
    ORIGINAL_SID = mp.get_property('options/sid')
end)

--reselect the subtitles if the audio is different from what was last used
local function reselect_subtitles()
    local initial = INITIAL_LOAD
    INITIAL_LOAD = false
    if not selection_enabled() then return end
    local audio = find_current_audio()
    if latest_audio.id ~= audio.id and (not initial or ORIGINAL_SID == 'auto') then
        msg.info("detected audio change - reselecting subtitles")
        select_tracks(audio)
    end
end

--setups the audio and subtitle track lists to use for the rest of the script
local function read_track_list()
    local track_list = mp.get_property_native("track-list", {})
    audio_tracks = {}
    sub_tracks = {}
    for _,track in ipairs(track_list) do
        if not track.lang then track.lang = "und" end

        if track.type == "audio" then
            table.insert(audio_tracks, track)
        elseif track.type == "sub" then
            table.insert(sub_tracks, track)
        end
    end
end

local function observe_audio_switches()
    mp.observe_property("aid", "string", reselect_subtitles)
end

local function unobserve_audio_switches()
    mp.unobserve_property(reselect_subtitles)
end

mp.add_hook('on_preloaded', 25, read_track_list)
mp.add_hook('on_preloaded', 26, function() latest_audio = predict_audio() end)

--events for file loading
mp.add_hook('on_preloaded', 30, function()
    if not selection_enabled() then return end
    if mp.get_property('options/sid') ~= 'auto' then return end
    preload()
end)

if o.observe_audio_switches then
    mp.register_event('file-loaded', observe_audio_switches)
    mp.add_hook('on_unload', 50, unobserve_audio_switches)
else
    mp.register_event('file-loaded', reselect_subtitles)
end

mp.observe_property('track-list/count', 'number', read_track_list)

--force subtitle selection during playback
mp.register_script_message("select-subtitles", async_load)

--toggle sub-select during playback
mp.register_script_message("sub-select", function(arg)
    if arg == "toggle" then ENABLED = not ENABLED
    elseif arg == "enable" then ENABLED = true
    elseif arg == "disable" then ENABLED = false end
    local str = "sub-select: ".. (ENABLED and "enabled" or "disabled")
    mp.osd_message(str)

    if not selection_enabled() then return end
    async_load()
end)