local fs      = require 'bee.filesystem'
local plat    = require 'bee.platform'
local config  = require 'config'
local util    = require 'utility'
local lang    = require 'language'
local client  = require 'client'
local lloader = require 'locale-loader'
local fsu     = require 'fs-utility'
local define  = require "proto.define"
local files   = require 'files'
local await   = require 'await'
local encoder = require 'encoder'
local ws      = require 'workspace.workspace'
local scope   = require 'workspace.scope'
local inspect = require 'inspect'
local jsonb   = require 'json-beautify'
local jsonc   = require 'jsonc'

local m = {}

m.metaPaths = {}

local function getDocFormater(uri)
    local version = config.get(uri, 'Lua.runtime.version')
    if client.getOption('viewDocument') then
        if version == 'Lua 5.1' then
            return 'HOVER_NATIVE_DOCUMENT_LUA51'
        elseif version == 'Lua 5.2' then
            return 'HOVER_NATIVE_DOCUMENT_LUA52'
        elseif version == 'Lua 5.3' then
            return 'HOVER_NATIVE_DOCUMENT_LUA53'
        elseif version == 'Lua 5.4' then
            return 'HOVER_NATIVE_DOCUMENT_LUA54'
        elseif version == 'Lua 5.5' then
            return 'HOVER_NATIVE_DOCUMENT_LUA55'
        elseif version == 'LuaJIT' then
            return 'HOVER_NATIVE_DOCUMENT_LUAJIT'
        end
    else
        if version == 'Lua 5.1' then
            return 'HOVER_DOCUMENT_LUA51'
        elseif version == 'Lua 5.2' then
            return 'HOVER_DOCUMENT_LUA52'
        elseif version == 'Lua 5.3' then
            return 'HOVER_DOCUMENT_LUA53'
        elseif version == 'Lua 5.4' then
            return 'HOVER_DOCUMENT_LUA54'
        elseif version == 'Lua 5.5' then
            return 'HOVER_DOCUMENT_LUA55'
        elseif version == 'LuaJIT' then
            return 'HOVER_DOCUMENT_LUAJIT'
        end
    end
end

local function convertLink(uri, text)
    local fmt = getDocFormater(uri)
    return text:gsub('%$([%.%w_%:]+)', function (name)
        local lastDot = ''
        if name:sub(-1) == '.' then
            name = name:sub(1, -2)
            lastDot = '.'
        end
        if fmt then
            return ('[%s](%s)'):format(name, lang.script(fmt, 'pdf-' .. name)) .. lastDot
        else
            return ('`%s`'):format(name) .. lastDot
        end
    end):gsub('§([%.%w]+)', function (name)
        local lastDot = ''
        if name:sub(-1) == '.' then
            name = name:sub(1, -2)
            lastDot = '.'
        end
        if fmt then
            return ('[§%s](%s)'):format(name, lang.script(fmt, name)) .. lastDot
        else
            return ('`%s`'):format(name) .. lastDot
        end
    end)
end

local function createViewDocument(uri, name)
    local fmt = getDocFormater(uri)
    if not fmt then
        return nil
    end
    name = name:match '[%w_%.%:]+'
    if name:sub(-1) == '.' then
        name = name:sub(1, -2)
    end
    return ('[%s](%s)'):format(lang.script.HOVER_VIEW_DOCUMENTS, lang.script(fmt, 'pdf-' .. name))
end

local function compileSingleMetaDoc(uri, script, metaLang, status)
    if not script then
        log.error('no meta?', uri)
        return nil
    end

    local middleBuf = {}
    local compileBuf = {}

    local last = 1
    for start, lua, finish in script:gmatch '()%-%-%-%#([^\n\r]*)()' do
        middleBuf[#middleBuf+1] = ('PUSH [===[%s]===]'):format(script:sub(last, start - 1))
        middleBuf[#middleBuf+1] = lua
        last = finish
    end
    middleBuf[#middleBuf+1] = ('PUSH [===[%s]===]'):format(script:sub(last))
    local middleScript = table.concat(middleBuf, '\n')
    local version, jit
    if config.get(uri, 'Lua.runtime.version') == 'LuaJIT' then
        version = 5.1
        jit = true
    else
        version = tonumber(config.get(uri, 'Lua.runtime.version'):sub(-3)) or 5.4
        jit = false
    end

    local disable = false
    local env = setmetatable({
        VERSION = version,
        JIT     = jit,
        PUSH    = function (text)
            compileBuf[#compileBuf+1] = text
        end,
        DES     = function (name)
            local des = metaLang[name]
            if not des then
                des = ('Miss locale <%s>'):format(name)
            end
            compileBuf[#compileBuf+1] = '---\n'
            for line in util.eachLine(des) do
                compileBuf[#compileBuf+1] = '---'
                compileBuf[#compileBuf+1] = convertLink(uri, line)
                compileBuf[#compileBuf+1] = '\n'
            end
            local viewDocument = createViewDocument(uri, name)
            if viewDocument then
                compileBuf[#compileBuf+1] = '---\n---'
                compileBuf[#compileBuf+1] = viewDocument
                compileBuf[#compileBuf+1] = '\n'
            end
            compileBuf[#compileBuf+1] = '---\n'
        end,
        DESTAIL = function (name)
            local des = metaLang[name]
            if not des then
                des = ('Miss locale <%s>'):format(name)
            end
            compileBuf[#compileBuf+1] = convertLink(uri, des):gsub('[\r\n]', '...')
            compileBuf[#compileBuf+1] = '\n'
        end,
        ALIVE   = function (str)
            local isAlive
            for piece in str:gmatch '[^%,]+' do
                if piece:sub(1, 1) == '>' then
                    local alive = tonumber(piece:sub(2))
                    if not alive or version >= alive then
                        isAlive = true
                        break
                    end
                elseif piece:sub(1, 1) == '<' then
                    local alive = tonumber(piece:sub(2))
                    if not alive or version <= alive then
                        isAlive = true
                        break
                    end
                else
                    local alive = tonumber(piece)
                    if not alive or version == alive then
                        isAlive = true
                        break
                    end
                end
            end
            if not isAlive then
                compileBuf[#compileBuf+1] = '---@deprecated\n'
            end
        end,
        DISABLE = function ()
            disable = true
        end,
    }, { __index = _ENV })

    util.saveFile((ROOT / 'log' / 'middleScript.lua'):string(), middleScript)

    local suc = xpcall(function ()
        assert(load(middleScript, middleScript, 't', env))()
    end, log.error)
    if not suc then
        log.debug('MiddleScript:\n', middleScript)
    end
    local text = table.concat(compileBuf)
    if disable and status == 'default' then
        return text, false
    end
    if status == 'disable' then
        return text, false
    end
    return text, true
end

local function loadMetaLocale(langID, result)
    result = result or {}
    local path = (ROOT / 'locale' / langID / 'meta.lua'):string()
    local localeContent = util.loadFile(path)
    if localeContent then
        xpcall(lloader, log.error, localeContent, path, result)
    end
    return result
end

local function initBuiltIn(uri)
    log.info('Init builtin library at:', uri)
    local scp      = scope.getScope(uri)
    local langID   = lang.id
    local version  = config.get(uri, 'Lua.runtime.version')
    local encoding = config.get(uri, 'Lua.runtime.fileEncoding')
    ---@type fs.path
    local metaPath = fs.path(METAPATH) / config.get(uri, 'Lua.runtime.meta'):gsub('%$%{(.-)%}', {
        version  = version,
        language = langID,
        encoding = encoding,
    })

    local metaLang = loadMetaLocale('en-US')
    if langID ~= 'en-US' then
        loadMetaLocale(langID, metaLang)
    end

    local metaPaths = {}
    scp:set('metaPaths', metaPaths)
    local suc = xpcall(function ()
        if not fs.exists(metaPath) then
            fs.create_directories(metaPath)
        end
    end, log.error)
    if not suc then
        log.warn('Init builtin failed.')
        return
    end
    local out = fsu.dummyFS()
    local templateDir = ROOT / 'meta' / 'template'
    for libName, builtinStatus in pairs(define.BuiltIn) do
        local status = config.get(uri, 'Lua.runtime.builtin')[libName] or builtinStatus
        log.debug('Builtin status:', libName, status)

        ---@type fs.path
        local libPath = templateDir / (libName .. '.lua')
        local metaDoc, include = compileSingleMetaDoc(uri, fsu.loadFile(libPath), metaLang, status)
        if metaDoc then
            metaDoc = encoder.encode(encoding, metaDoc, 'auto')

            local outputLibName = libName:gsub('%.', '/') .. '.lua'
            if outputLibName ~= libName then
                out:createDirectories(fs.path(outputLibName):parent_path())
            end

            local ok, err = out:saveFile(outputLibName, metaDoc)
            if not ok then
                log.debug("Save Meta File Failed:", err)
                goto CONTINUE
            end

            local outputPath = metaPath / outputLibName
            m.metaPaths[outputPath:string()] = true
            log.debug('Meta path:', outputPath:string())

            if include then
                metaPaths[#metaPaths+1] = outputPath:string()
            end
        end
        ::CONTINUE::
    end
    local result = fsu.fileSync(out, metaPath)
    if #result.err > 0 then
        log.warn('File sync error:', inspect(result))
    end
end

---@param libraryDir fs.path
---@return table?
local function loadSingle3rdConfigFromJson(libraryDir)
    local path = libraryDir / 'config.json'
    local configText = fsu.loadFile(path)
    if not configText then
        return nil
    end

    local suc, cfg = xpcall(jsonc.decode_jsonc, function (err)
        log.error('Decode config.json failed at:', libraryDir:string(), err)
    end, configText)
    if not suc then
        return nil
    end

    if type(cfg) ~= 'table' then
        log.error('config.json must be an object:', libraryDir:string())
        return nil
    end

    return cfg
end

---@param libraryDir fs.path
---@return table?
local function loadSingle3rdConfigFromLua(libraryDir)
    local path = libraryDir / 'config.lua'
    local configText = fsu.loadFile(path)
    if not configText then
        return nil
    end

    local env = setmetatable({}, { __index = _G })
    local f, err = load(configText, '@' .. libraryDir:string(), 't', env)
    if not f then
        log.error('Load config.lua failed at:', libraryDir:string(), err)
        return nil
    end

    local suc = xpcall(f, function (err)
        log.error('Load config.lua failed at:', libraryDir:string(), err)
    end)

    if not suc then
        return nil
    end

    local cfg = {}
    for k, v in pairs(env) do
        cfg[k] = v
    end

    return cfg
end

---@param libraryDir fs.path
local function loadSingle3rdConfig(libraryDir)
    local cfg = loadSingle3rdConfigFromJson(libraryDir)
    if not cfg then
        cfg = loadSingle3rdConfigFromLua(libraryDir)
        if not cfg then
            return
        end
        local jsonbuf = jsonb.beautify(cfg)
        client.requestMessage('Info', lang.script.WINDOW_CONFIG_LUA_DEPRECATED, {
            lang.script.WINDOW_CONVERT_CONFIG_LUA,
        }, function (_action, index)
            if index == 1 and jsonbuf then
                fsu.saveFile(libraryDir / 'config.json', jsonbuf)
                fsu.fileRemove(libraryDir / 'config.lua')
            end
        end)
    end

    cfg.path = libraryDir:filename():string()
    cfg.name = cfg.name or cfg.path

    if fs.exists(libraryDir / 'plugin.lua') then
        cfg.plugin = true
    end

    if cfg.words then
        for i, word in ipairs(cfg.words) do
            cfg.words[i] = '([%w_]?)' .. word .. '([%w_]?)'
        end
    end
    if cfg.files then
        for i, fname in ipairs(cfg.files) do
            local filename = fname
            if plat.os == 'windows' then
                filename = filename:gsub('/', '\\')
            else
                filename = filename:gsub('\\', '/')
            end
            cfg.files[i] = '([%w_]?)' .. filename .. '([%w_]?)'
        end
    end

    return cfg
end

local innerThirdDir = ROOT / 'meta' / '3rd'

local function load3rdConfigInDir(dir, configs, inner)
    if not fs.is_directory(dir) then
        return
    end
    for libraryDir in fs.pairs(dir) do
        local suc, res = xpcall(loadSingle3rdConfig, log.error, libraryDir)
        if suc and res then
            if inner then
                res.dirname = ('${3rd}/%s'):format(res.path)
            else
                res.dirname = ('%s/%s'):format(dir:string(), res.path)
            end
            configs[#configs+1] = res
        end
    end
end

local function load3rdConfig(uri)
    local scp = scope.getScope(uri)
    local configs = scp:get 'thirdConfigsCache'
    if configs then
        return configs
    end
    configs = {}
    scp:set('thirdConfigsCache', configs)
    load3rdConfigInDir(innerThirdDir, configs, true)
    local thirdDirs = config.get(uri, 'Lua.workspace.userThirdParty')
    for _, thirdDir in ipairs(thirdDirs) do
        load3rdConfigInDir(fs.path(thirdDir), configs)
    end
    return configs
end

local function apply3rd(uri, cfg, onlyMemory)
    local changes = {}
    if cfg.settings then
        for key, value in pairs(cfg.settings) do
            if type(value) == 'table' then
                if #value == 0 then
                    for k, v in pairs(value) do
                        changes[#changes+1] = {
                            key    = key,
                            action = 'prop',
                            prop   = k,
                            value  = v,
                            uri    = uri,
                        }
                    end
                else
                    for _, v in ipairs(value) do
                        changes[#changes+1] = {
                            key    = key,
                            action = 'add',
                            value  = v,
                            uri    = uri,
                        }
                    end
                end
            else
                changes[#changes+1] = {
                    key    = key,
                    action = 'set',
                    value  = value,
                    uri    = uri,
                }
            end
        end
    end

    if cfg.plugin then
        changes[#changes+1] = {
            key    = 'Lua.runtime.plugin',
            action = 'set',
            value  = ('%s/plugin.lua'):format(cfg.dirname),
            uri    = uri,
        }
    end

    changes[#changes+1] = {
        key    = 'Lua.workspace.library',
        action = 'add',
        value  = ('%s/library'):format(cfg.dirname),
        uri    = uri,
    }

    client.setConfig(changes, onlyMemory)
end

local hasAsked = {}
---@async
local function askFor3rd(uri, cfg, checkThirdParty)
    if hasAsked[cfg.name] then
        return nil
    end

    if checkThirdParty == 'Apply' then
        apply3rd(uri, cfg, false)
    elseif checkThirdParty == 'ApplyInMemory' then
        apply3rd(uri, cfg, true)
    elseif checkThirdParty == 'Disable' then
        return nil
    elseif checkThirdParty == 'Ask' then
        hasAsked[cfg.name] = true
        local applyAndSetConfig = lang.script.WINDOW_APPLY_WHIT_SETTING
        local applyInMemory = lang.script.WINDOW_APPLY_WHITOUT_SETTING
        local dontShowAgain   = lang.script.WINDOW_DONT_SHOW_AGAIN
        local result = client.awaitRequestMessage('Info'
            , lang.script('WINDOW_ASK_APPLY_LIBRARY', cfg.name)
            , {applyAndSetConfig, applyInMemory, dontShowAgain}
        )
        if not result then
            -- "If none got selected"
            -- See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#window_showMessageRequest
            return nil
        end
        if result == applyAndSetConfig then
            apply3rd(uri, cfg, false)
        elseif result == applyInMemory then
            apply3rd(uri, cfg, true)
        else
            client.setConfig({
                {
                    key    = 'Lua.workspace.checkThirdParty',
                    action = 'set',
                    value  = 'Disable',
                    uri    = uri,
                },
            }, false)
        end
    end
end

---@param a string
---@param b string
---@return boolean
local function wholeMatch(a, b)
    local captures = {
        a:match(b),
    }
    return captures[1] == '' and captures[#captures] == ''
end

local function check3rdByWords(uri, configs, checkThirdParty)
    if not files.isLua(uri) then
        return
    end
    local id = 'check3rdByWords:' .. uri
    await.close(id)
    await.call(function () ---@async
        await.sleep(0.1)
        local text = files.getText(uri)
        if not text then
            return
        end
        for _, cfg in ipairs(configs) do
            if not cfg.words then
                goto CONTINUE
            end
            if hasAsked[cfg.name] then
                goto CONTINUE
            end
            local library = ('%s/library'):format(cfg.dirname)
            if util.arrayHas(config.get(uri, 'Lua.workspace.library'), library) then
                goto CONTINUE
            end
            for _, word in ipairs(cfg.words) do
                await.delay()
                if wholeMatch(text, word) then
                    log.info('Found 3rd library by word: ', word, uri, library, inspect(config.get(uri, 'Lua.workspace.library')))
                    ---@async
                    await.call(function ()
                        askFor3rd(uri, cfg, checkThirdParty)
                    end)
                    return
                end
            end
            ::CONTINUE::
        end
    end, id)
end

local function check3rdByFileName(uri, configs, checkThirdParty)
    local path = ws.getRelativePath(uri)
    if not path then
        return
    end
    local id = 'check3rdByFileName:' .. uri
    await.close(id)
    await.call(function () ---@async
        await.sleep(0.1)
        for _, cfg in ipairs(configs) do
            if not cfg.files then
                goto CONTINUE
            end
            if hasAsked[cfg.name] then
                goto CONTINUE
            end
            local library = ('%s/library'):format(cfg.dirname)
            if util.arrayHas(config.get(uri, 'Lua.workspace.library'), library) then
                goto CONTINUE
            end
            for _, filename in ipairs(cfg.files) do
                await.delay()
                if wholeMatch(path, filename) then
                    log.info('Found 3rd library by filename: ', filename, uri, library, inspect(config.get(uri, 'Lua.workspace.library')))
                    ---@async
                    await.call(function ()
                        askFor3rd(uri, cfg, checkThirdParty)
                    end)
                    return
                end
            end
            ::CONTINUE::
        end
    end, id)
end

---@async
local function check3rd(uri)
    if ws.isIgnored(uri) then
        return
    end
    local checkThirdParty = config.get(uri, 'Lua.workspace.checkThirdParty')
    -- Backwards compatability: `checkThirdParty` used to be a boolean.
    if not checkThirdParty or checkThirdParty == 'Disable' then
        return
    elseif checkThirdParty == true then
        checkThirdParty = 'Ask'
    end
    local scp = scope.getScope(uri)
    if not scp:get 'canCheckThirdParty' then
        return
    end
    local thirdConfigs = load3rdConfig(uri) or false
    if not thirdConfigs then
        return
    end
    check3rdByWords(uri, thirdConfigs, checkThirdParty)
    check3rdByFileName(uri, thirdConfigs, checkThirdParty)
end

local function check3rdOfWorkspace(suri)
    local scp = scope.getScope(suri)
    scp:set('thirdConfigsCache', nil)
    scp:set('canCheckThirdParty', true)
    local id = 'check3rdOfWorkspace:' .. scp:getName()
    await.close(id)
    ---@async
    await.call(function ()
        ws.awaitReady(suri)
        for uri in files.eachFile(suri) do
            check3rd(uri)
        end
        for uri in files.eachDll() do
            check3rd(uri)
        end
    end, id)
end

config.watch(function (uri, key, _value, _oldValue)
    if key:find '^Lua.runtime' then
        initBuiltIn(uri)
    end
    if key == 'Lua.workspace.checkThirdParty'
    or key == 'Lua.workspace.userThirdParty'
    or key == '' then
        check3rdOfWorkspace(uri)
    end
end)

---@async
files.watch(function (ev, uri)
    if ev == 'update'
    or ev == 'dll' then
        await.sleep(1)
        check3rd(uri)
    end
end)

ws.watch(function (ev, uri)
    if ev == 'startReload' then
        initBuiltIn(uri)
    end
end)

return m
