2023年政策修订增补工作正在进行中,欢迎参与!
  • Moegirl.ICU:萌娘百科流亡社群 581077156(QQ),欢迎对萌娘百科运营感到失望的编辑者加入
  • Moegirl.ICU:账号认领正在试运行,有意者请参照账号认领流程

Module:Sandbox/Sam01101/LuaTest

萌娘百科,万物皆可萌的百科全书!转载请标注来源页面的网页链接,并声明引自萌娘百科。内容不可商用。
跳转到导航 跳转到搜索
Template-info.svg 模块文档  [创建] [刷新]
---@diagnostic disable: redefined-local
--region Logging

---@class Log
---@field level nil | number
---@field debug fun(self: Log, ...: any): nil
---@field info fun(self: Log, ...: any): nil
---@field warn fun(self: Log, ...: any): nil
---@field error fun(self: Log, ...: any): nil
local log = {
    -- Default level is info
    level = 2
}
---@return Log
function log:new()
    self = setmetatable({}, { __index = self })
    local level_color_map = {
        info = "black",
        warn = "orange",
        error = "red"
    }
    for i, level in ipairs { "debug", "info", "warn", "error" } do
        ---@param _ Log
        ---@param ... any
        ---@return nil
        ---@diagnostic disable-next-line: assign-type-mismatch
        self[level] = function(_, ...)
            if not self.level or self.level > i then
                return
            end
            local args = { ... }
            for i, v in ipairs(args) do
                if type(v) == "table" then
                    args[i] = mw.dumpObject(v)
                end
            end
            table.insert(args, 1, ("[%s]"):format(level:upper()))
            local msg = mw.allToString(unpack(args))
            mw.log(msg)
            if level_color_map[level] then
                mw.addWarning(("<p style=\"display: contents\">{{color|%s|%s}}<br></p>"):format(level_color_map[level],
                    msg))
            end
        end
    end
    return self
end

function log:set_level(lvl)
    if not lvl or lvl == "" then
        self.level = nil
        return self
    else
        lvl = tostring(lvl):lower()
    end
    for i, pattern in ipairs {
        "^d", -- debug
        "^i", -- info
        "^w", -- warning
        "^e" -- error
    } do
        if lvl:match(pattern) then
            mw.log(("Set log level: %d"):format(i))
            self.level = i
            break
        end
    end
end

--endregion
--region Utils

local utils
utils = {
    ---@param val any
    ---@return boolean
    tobool = function(val)
        if not val then
            return false
        elseif type(val) == "boolean" then
            return val
        end
        return (
            (type(val) == "number" and val ~= 0) or
                (type(val) == "string" and (val ~= "" or val:find("^t"))) or
                (type(val) == "table" and next(val) ~= nil)
            )
    end,
    patterns = {
        bt = "^bt(%d+)$",
        tab = "^tab(%d+)$",
        bticon = "^bticon(%d+)$"
    },
    shallow_copy = function(orig)
        local orig_type = type(orig)
        local copy
        if orig_type == "table" then
            copy = {}
            for orig_key, orig_value in pairs(orig) do
                copy[orig_key] = orig_value
            end
        else -- number, string, boolean, etc
            copy = orig
        end
        return copy
    end,
    ---@param data table
    ---@param indent number | nil
    ---@param level number | nil
    json_encode = function(data, indent, level)
        -- Don't use .. to concat, use format
        local result = {}
        local indent, level = indent or 2, level or 1
        local indent_str = string.rep(" ", indent * level)
        if type(data) == "table" then
            -- Array: #data > 0 | Object
            if #data > 0 then
                table.insert(result, "[")
                for i, v in ipairs(data) do
                    table.insert(result, utils.json_encode(v, indent, level + 1) .. (i ~= #data and "," or ""))
                end
                table.insert(result, "]")
            else
                table.insert(result, "{")
                local i = 0
                for k, v in pairs(data) do
                    i = i + 1
                    table.insert(result,
                        string.format("%q: %s", k, utils.json_encode(v, indent, level + 1)) ..
                        (i ~= #data and "," or "")
                    )
                end
                table.insert(result, "}")
            end
        elseif type(data) == "string" then
            table.insert(result, string.format("%q", data))
        else
            table.insert(result, tostring(data))
        end
        return table.concat(result, (indent > 0 and "\n" or "") .. indent_str)
    end
}

---@param orig table
---@return table
function table.clone(orig)
    return { unpack(orig) }
end

--endregion
--region Faker (Simple data generating, not really faker module)

---@class Faker
local Faker = {}

---Generate name
---@param length number | nil
---@return string
function Faker:gen_name(length)
    length = length or math.random(5, 10)
    -- First letter must be uppercase
    local name = string.char(math.random(65, 90))
    for _ = 1, length - 1 do
        name = name .. string.char(math.random(97, 122))
    end
    return name
end

---Generate name, e.g. "John Smith"
---@return string
function Faker:full_name()
    return mw.ustring.format("%s %s", self:gen_name(), self:gen_name())
end

---Generate Lorem ipsum
---@param length number | nil Count as words
---@param fixed_head boolean | nil Start with "Lorem ipsum dolor sit amet"
---@return string
function Faker:lorem(length, fixed_head)
    length = length or math.random(10, 20)
    local lorem = {
        "Lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", "elit",
        "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", "et", "dolore",
        "magna", "aliqua", "Ut", "enim", "ad", "minim", "veniam", "quis", "nostrud",
        "exercitation", "ullamco", "laboris", "nisi", "ut", "aliquip", "ex", "ea",
        "commodo", "consequat", "Duis", "aute", "irure", "dolor", "in", "reprehenderit",
        "in", "voluptate", "velit", "esse", "cillum", "dolore", "eu", "fugiat", "nulla",
        "pariatur", "Excepteur", "sint", "occaecat", "cupidatat", "non", "proident",
        "sunt", "in", "culpa", "qui", "officia", "deserunt", "mollit", "anim", "id",
        "est", "laborum"
    }
    local text, uppercase = {}, true
    local comma, footstop = ",", "."
    if fixed_head then
        table.insert(text, "Lorem ipsum dolor sit amet")
        uppercase = false
    end
    for i = 1, length do
        local word = lorem[math.random(1, #lorem)]
        word = uppercase and word:gsub("^%l", string.upper) or word:lower()
        if uppercase then
            uppercase = false
        end
        if i % math.random(5, 10) == 0 then
            word = word .. comma
        elseif i % math.random(5, 10) == 0 then
            word = word .. footstop
            uppercase = true
        end
        table.insert(text, word)
    end
    if text[#text]:sub(-1) == comma then
        text[#text] = text[#text]:sub(1, -2) .. footstop
    elseif text[#text]:sub(-1) ~= footstop then
        text[#text] = text[#text] .. footstop
    end
    return table.concat(text, " ")
end

--endregion
--region JSON Schema
---@diagnostic disable: unbalanced-assignments

--[[
JSON Schema Validation
Note: This might slightly different from the official JSON Schema

# General Options

- `type`: The type field, see below.
    - `_`: Notes for this key, used in generating docs.
    - `required`: Required fields.
    - `default`: Default value.
    - `faker`: Param to control how the data is generated.
        - When type is set to `false` (Boolean), this field will not be generated.
        - When type is a number for `array` and `string`, this field will be the length of the type.

Note: `required` and `default` cannot be used together or it will cause conflict.

# Type

The `type` field

## String

Options

- `enum`: List of enumerate values.

Note: When `default` is set, it must match one of the enum value.

## Number

Options

- `min`: Minimum value that's equal or greater than.

Note: When `default` is set, it must be in range of `min`.

## Boolean

_No options_

## Array

Options

- `items`: Type of items.

## Object

Options

- `keys`: Type of keys.
          Can be a callback function(key: string) with [true, nil] or [false, msg] result.

--]]

---@class JsonSchema
---@field schema table<string, table>
local JsonSchema = {}

---@param schema table<string, table>
---@return JsonSchema | nil
function JsonSchema:new(schema)
    if type(schema) ~= "table" then
        return log:error(("Schema 必须为 `table`,而不是 `%s`。"):format(type(schema)))
    end
    local self = setmetatable({
        schema = schema
    }, { __index = self })
    local err = self:verify_schema()
    if err then
        return log:error("验证 JSON 架构失败:", err)
    end
    return self
end

---Verify the schema
---@return nil | string
function JsonSchema:verify_schema()
    local checks
    checks = {
        ---@param key string
        ---@param opt table
        ---@reutrn nil | string
        _ = function(key, opt)
            -- Entry
            local key_types = {
                _ = "string",
                required = "boolean",
                -- String
                enum = "table",
                -- Number
                min = "number",
                -- Array
                items = "table",
                -- Object
                -- It can't be checked since `keys` have different types.
            }
            if not type(opt.type) then
                return ("[%s] `type` 未填入"):format(key)
            elseif not checks[opt.type] then
                return ("[%s] 未知类型 `%s`"):format(key, tostring(opt.type))
            end
            for key, type_name in pairs(key_types) do
                if opt[key] ~= nil and type(opt[key]) ~= type_name then
                    return ("[%s] 类型应为 `%s` 但实际为 `%s`"):format(key, type_name, type(opt[key]))
                end
            end
            if type(opt.faker) ~= "nil" and type(opt.faker) ~= "number" and type(opt.faker) ~= "boolean" then
                return ("[%s] 类型应为 `number` 或 `boolean` 但实际为 `%s`"):format(key, type(opt.faker))
            elseif type(opt.faker) == "boolean" and opt.faker then
                return ("[%s] `faker` 不能为 `true`"):format(key)
            end
            if opt.default and type(opt.default) ~= opt.type and not (
                -- Known issue: Values inside `array` and `object` were not checked.
                type(opt.default) == "table" and (opt.type == "array" or opt.type == "object")
                ) then
                return ("[%s] 默认值类型应为 `%s` 但实际为 `%s`"):format(key, opt.type, type(opt.default))
            end
            if opt.required and opt.default then
                return ("[%s] `required` 和 `default` 不能同时使用"):format(key)
            end
            return checks[opt.type](key, opt)
        end,
        ---@param key string
        ---@param opt table
        ---@reutrn nil | string
        ["string"] = function(key, opt)
            if opt.enum and opt.default then
                local enum_matched, default = false, opt.default
                for _, value in ipairs(opt.enum) do
                    if value == default then
                        enum_matched = true
                        break
                    end
                end
                if not enum_matched then
                    return ("[%s] `default` 值 \"%s\" 必须在 `enum` 范围内: %s"):format(key, default,
                        table.concat(opt.enum, ", "))
                end
            end
        end,
        ---@param key string
        ---@param opt table
        ---@reutrn nil | string
        ["number"] = function(key, opt)
            if opt.min and opt.default and opt.default < opt.min then
                return ("[%s] `default` 的值 %d 必须大于等于 `min` 值 %s"):format(key, opt.default, opt.min)
            end
        end,
        ---@param key string
        ---@param opt table
        ["boolean"] = function(key, opt)
            -- Nothing to check
        end,
        ---@param key string
        ---@param opt table
        ---@reutrn nil | string
        ["array"] = function(key, opt)
            if opt.items then
                return checks._(("%s[]"):format(key), opt.items)
            end
        end,
        ---@param key string
        ---@param opt table
        ---@reutrn nil | string
        ["object"] = function(key, opt)
            if opt.keys and type(opt.keys) == "table" then
                for k, v in pairs(opt.keys) do
                    local err = checks._(("%s.%s"):format(key, k), v)
                    if err then
                        return err
                    end
                end
            end
        end
    }
    for key, option in pairs(self.schema) do
        -- Check for invalid keys
        if type(key) ~= "string" then
            return ("Key 的名称应为 `string` 类型,但解析成了 `%s`"):format(type(key))
        elseif key == "" then
            return "Key 不能为空值"
        end
        -- Check for type
        if type(option.type) ~= "string" then
            return ("`type` %s 的类型应为 `string` 类型,但解析成了 `%s`"):format(key, type(option.type))
        end
        local has_key = false
        for typ_key in pairs(checks) do
            if option.type == typ_key then
                has_key = true
                break
            end
        end
        if not has_key then
            return ("[%s] 未知类型 %s"):format(key, type(option.type))
        end
        -- Verify options
        return checks._(key, option)
    end
end

---Verify data with the schema
---@param data table
---@return table | string
function JsonSchema:verify_data(data)
    local checks
    checks = {
        ---@param schema_data table
        ---@param key string
        ---@param value any
        ---@return any, nil | string
        _ = function(schema_data, key, value)
            -- Check for default value
            if value == nil and schema_data.default ~= nil then
                value = schema_data.default
            end
            -- Update value (Only array and object type)
            if schema_data.type == "array" or schema_data.type == "object" then
                if schema_data.default ~= nil then
                    for k, v in pairs(schema_data.default) do
                        if value[k] == nil then
                            value[k] = v
                        end
                    end
                elseif value == nil then
                    value = {}
                end
            end
            -- Check for value type
            if type(value) ~= schema_data.type and not (
                (schema_data.type == "array" or schema_data.type == "object") and type(value) == "table"
                ) then
                return nil, ("[%s] 类型不匹配: 预期为 `%s`, 实际为 `%s`"):format(key, schema_data.type,
                    type(value))
            end
            -- Check for required
            if schema_data.required and value == nil then
                -- There's requirement check in array and object.
                return nil, ("[%s] 数据不能为空"):format(key)
            end
            -- Check for data base on type
            return checks[schema_data.type](schema_data, key, value)
        end,
        ["string"] = function(schema_data, key, value)
            if schema_data.enum then
                local enum_matched = false
                for _, enum in ipairs(schema_data.enum) do
                    if enum == value then
                        enum_matched = true
                        break
                    end
                end
                if not enum_matched then
                    return nil,
                        ("[%s] 值 `%s` 必须在 `enum` 范围内: %s"):format(key, value,
                            table.concat(schema_data.enum, ", "))
                end
            end
            return value
        end,
        ["number"] = function(schema_data, key, value)
            if schema_data.min and value < schema_data.min then
                return nil, ("[%s] 值 %s 必须大于等于 `min` 值 %s"):format(key, value, schema_data.min)
            end
            return value
        end,
        ["boolean"] = function(schema_data, key, value)
            return value
        end,
        ["array"] = function(schema_data, key, value)
            if schema_data.items then
                if #value == 0 and schema_data.items.required then
                    return nil, ("[%s] 列表不能为空"):format(key)
                end
                for i, v in ipairs(value) do
                    local res, err = checks._(schema_data.items, ("%s[%i]"):format(key, i), v)
                    if err then
                        return nil, error
                    end
                    value[i] = res
                end
            end
            return value
        end,
        ["object"] = function(schema_data, key, value)
            if type(schema_data.keys) == "table" then
                local object_empty = true
                for k, val_schema in pairs(schema_data.keys) do
                    if value[k] == nil and val_schema.required then
                        return nil, ("[%s.%s] 数据不能为空"):format(key, k)
                    elseif value[k] ~= nil then
                        local res, err = checks._(val_schema, ("%s.%s"):format(key, k), value[k])
                        if err then
                            return nil, err
                        end
                        value[k] = res
                        object_empty = false
                    end
                end
                if object_empty and schema_data.keys.required then
                    return nil, ("[%s] 数据不能为空"):format(key)
                end
            elseif type(schema_data.keys) == "function" then
                local verify_func = schema_data.keys
                for k in pairs(value) do
                    local res, err = verify_func(k)
                    if not res then
                        return nil, ("[%s.%s] 函数检查失败: %s"):format(key, k, err)
                    end
                end
            end
            return value
        end
    }
    for key, schema_data in pairs(self.schema) do
        local res, err = checks._(schema_data, key, data[key])
        if err then
            return err
        end
        data[key] = res
    end
    return data
end

---Generate docs for schema
---@return string
function JsonSchema:gen_docs()
    local type_name_map = {
        string = "字符串 (String)",
        boolean = "布尔值 (Boolean)",
        number = "数字 (Number)",
        array = "数组 (Array)",
        object = "对象 (Object)",
    }

    ---@param schema_data table<string, table>
    local function get_notes(schema_data)
        local notes = {}
        if schema_data._ then
            table.insert(notes, schema_data._)
        end
        if schema_data.type == "string" and schema_data.enum then
            local enums = {}
            for _, enum in ipairs(schema_data.enum) do
                table.insert(enums, ("<kbd>%s</kbd>"):format(enum))
            end
            if schema_data._ then
                table.insert(notes, "")
            end
            table.insert(notes, ("可选值: %s"):format(table.concat(enums, ", ")))
        elseif schema_data.type == "number" and schema_data.min then
            if schema_data._ then
                table.insert(notes, "")
            end
            table.insert(notes, ("最小值: <code>%s</code>"):format(schema_data.min))
        end
        if #notes == 0 then
            return ""
        end
        return table.concat(notes, "<br>")
    end

    ---@param key string
    ---@param type string
    ---@return string
    local function get_key_entry(key, type)
        if type == "array" then
            return ("%s[ ]"):format(key)
        elseif type == "object" then
            return ("%s{ }"):format(key)
        end
        return key
    end

    ---@param key string
    ---@param schema_data table<string, any>
    ---@return string
    local function gen_docs(key, schema_data)
        -- MeidaWiki table format
        local default = schema_data.type == "boolean" and utils.tobool(schema_data.default) or
            (schema_data.default or nil)
        local docs = {
            ("| %s || %s || %s || %s || %s"):format(
                ("<code>%s</code>"):format(get_key_entry(key, schema_data.type)),
                type_name_map[schema_data.type] or ("? (%s)"):format(tostring(schema_data.type)),
                schema_data.required and "✅" or "",
                default and ("<code>%s</code>"):format(default) or "",
                get_notes(schema_data)
            )
        }
        if schema_data.type == "array" and schema_data.items then
            table.insert(docs, gen_docs(("%s[i]"):format(("&nbsp;"):rep(#key:gsub("&nbsp;", " "))), schema_data.items))
        elseif schema_data.type == "object" and schema_data.keys then
            if type(schema_data.keys) == "table" then
                for k, v in pairs(schema_data.keys) do
                    table.insert(docs, gen_docs(("%s{ }.%s"):format(("&nbsp;"):rep(#key:gsub("&nbsp;", " ")), k), v))
                end
            elseif type(schema_data.keys) == "function" then
                table.insert(docs, ("| %s || %s || %s || %s || %s"):format(
                    ("<code>%s.key</code>"):format(key),
                    "<nowiki>*函数 (Function)</nowiki>",
                    "",
                    "",
                    "自定义验证函数"
                ))
            end
        end
        return table.concat(docs, "\n|-\n")
    end

    ---Generate Sample JSON from schema using Faker
    ---@return string
    local function gen_sample_json()
        local faker = Faker
        ---@param schema_data table<string, any>
        ---@return any
        local function gen_sample_json_data(schema_data)
            if schema_data.default then
                return schema_data.default
            elseif schema_data.faker == false then
                return
            end
            if schema_data.type == "string" then
                if schema_data.enum then
                    return schema_data.enum[math.random(1, #schema_data.enum)]
                elseif type(schema_data.faker) == "number" then
                    return faker:lorem(schema_data.faker)
                end
                return faker:full_name()
            elseif schema_data.type == "boolean" then
                return utils.tobool(math.random(0, 1))
            elseif schema_data.type == "number" then
                return math.random(schema_data.min or 0, 100)
            elseif schema_data.type == "array" then
                local items = {}
                local repeat_num = type(schema_data.faker) == "number" and schema_data.faker > 0 and schema_data.faker
                    or math.random(1, 10)
                for i = 1, repeat_num do
                    table.insert(items, gen_sample_json_data(schema_data.items))
                end
                return items
            elseif schema_data.type == "object" then
                local obj = {}
                if type(schema_data.keys) == "table" then
                    for k, v in pairs(schema_data.keys) do
                        obj[k] = gen_sample_json_data(v)
                    end
                    -- elseif type(schema_data.keys) == "function" then
                    --     for i = 1, math.random(1, 5) do
                    --         obj[faker:gen_name(1)] = gen_sample_json_data(schema_data)
                    --     end
                end
                return obj
            end
        end

        local json = {}
        for key, schema_data in pairs(self.schema) do
            json[key] = gen_sample_json_data(schema_data)
        end
        return utils.json_encode(json)
    end

    local docs = {
        "{{mbox|text=此架构文档为自动生成,请勿手动修改。}}",
        "",
        "{| class=\"wikitable mw-collapsible mw-collapsed\" style=\"margin: auto\"",
        "! colspan=\"5\" | JSON 架构",
        "|-",
        "! 键 !! 类型 !! 必填 !! 默认值 !! 说明",
        "|-",
    }
    for key, schema_data in pairs(self.schema) do
        table.insert(docs, gen_docs(key, schema_data))
        table.insert(docs, "|-")
    end
    docs[#docs] = "|}"
    table.insert(docs, ("{{Hide|JSON 示范|width = auto; margin: auto|\n%s\n}}"):format(
        tostring(mw.html.create("pre"):css("margin", "auto"):attr("lang", "json"):wikitext(gen_sample_json()))
    ))
    return table.concat(docs, "\n")
end

---Verify data using schema
---@param raw_data string Raw JSON string
---@return boolean, nil | table | string
function JsonSchema:parse_data(raw_data)
    if not raw_data or raw_data == "" then
        log:warn("传入的 JSON 数据为空。")
        return true
    elseif type(raw_data) ~= "string" then
        return false, ("类型错误: `raw_data` 必须为字符串,但是传入了 `%s` 类型。"):format(type(raw_data))
    end
    local ok, json = pcall(mw.text.jsonDecode, raw_data, mw.text.JSON_TRY_FIXING)
    if not ok then
        return false, ("JSON 解析失败: %s"):format(tostring(json))
    end
    if type(json) ~= "table" then
        return false, ("JSON 类型错误: 传入的 JSON 顶层数据必须为一张表,但是解析成了 `%s` 类型。"
            ):format(type(json))
    elseif not next(json) then -- empty table
        log:warn("传入的 JSON 数据为空。")
        return true
    end
    local res = self:verify_data(json)
    if type(res) == "string" then
        return false, ("数据检查失败: %s"):format(res)
    end
    return true, res
end

--endregion
--region Routes

---@class RouteOptions
local RouteOptions = {
    action_log = {}
}

---@param routes table
---@return RouteOptions
function RouteOptions:new(routes)
    return setmetatable(routes, { __index = self })
end

---@param id string
---@param get_index boolean | nil
---@return table | number | nil
function RouteOptions:get(id, get_index)
    for i, route_opts in ipairs(self) do
        if route_opts.id == id then
            return get_index and i or route_opts
        end
    end
end

---Get ID List
---@param mark_dupe boolean Check for duped id and mark as (Duped)
function RouteOptions:get_ids(mark_dupe)
    local ids = {}
    for _, route_opts in ipairs(self) do
        local route_id = route_opts.id
        if mark_dupe then
            for _, id in ipairs(ids) do
                if id == route_opts.id then
                    route_id = ("{{color|gray|%s (重复)}}"):format(route_opts.id)
                    break
                end
            end
        end
        table.insert(ids, route_id)
    end
    return ids
end

---@param id string
---@return boolean
function RouteOptions:remove(id)
    local idx = self:get(id, true)
    if type(idx) ~= "number" then
        self:log_route_not_found("delete", id)
        return false
    end
    table.remove(self, idx)
    self:log("delete", id)
    return true
end

---@param id string
---@param data table
---@return boolean
function RouteOptions:insert(id, data)
    local idx = self:get(id, true)
    if type(idx) ~= "number" then
        self:log_route_not_found("insert", id)
        return false
    end
    if not data.id then
        data.id = id
    end
    table.insert(self, idx, data)
    self:log("insert", id, { data = data, id_changed = data.id ~= id })
    return true
end

---@param id string
---@param data table
---@return boolean
function RouteOptions:replace(id, data)
    local idx = self:get(id, true)
    if type(idx) ~= "number" then
        self:log_route_not_found("replace", id)
        return false
    end
    if not data.id then
        data.id = id
    end
    self:log("replace", id, { data = data, id_changed = data.id ~= id })
    self[idx] = data
    return true
end

---@param id string
---@param data table
---@return boolean
function RouteOptions:append(id, data)
    if id == "" then -- Append to last
        table.insert(self, data)
        self:log("append", id, { last = true, data = data, id_changed = true })
        return true
    end
    local idx = self:get(id, true)
    if type(idx) ~= "number" then
        self:log_route_not_found("append", id)
        return false
    end
    if not data.id then
        data.id = id
    end
    self:log("append", id, { data = data, id_changed = data.id ~= id })
    table.insert(self, idx + 1, data)
    return true
end

function RouteOptions:log_route_not_found(action, id)
    self:log(action, id, {
        error = true,
        code = "not_found"
    })
end

---@param action string
---@param id string
---@param data table | nil
function RouteOptions:log(action, id, data)
    if action ~= "delete" and data then
        data.id_list = self:get_ids(true)
    end
    table.insert(self.action_log, {
        action = action,
        id = id,
        data = data
    })
end

---Add chapter diff in last log
---@param old_chapter string
---@param new_chapter string
function RouteOptions:log_chapter(old_chapter, new_chapter)
    if old_chapter == new_chapter then
        return
    end
    self.action_log[#self.action_log].diff = {
        old = old_chapter,
        new = new_chapter
    }
end

---Format log to human readable
---@param character_name string
---@return string
function RouteOptions:format_log(character_name)
    local action_map = {
        delete = "移除",
        insert = "插入",
        replace = "替换",
        append = "添加"
    }
    local err_code = {
        not_found = "未找到"
    }
    local msgs = setmetatable({}, { __call = function(self, msg) self[#self + 1] = msg end })
    for _, log_data in ipairs(self.action_log) do
        local details = log_data.data
        local color_code = (details and details.error) and "red" or "gray"
        local append_new_mode = (log_data.action == "append" and details and details.last)
        if append_new_mode then
            msgs(("➡ [追加选项]"):format(color_code))
        else
            msgs(("➡ [%s] {{color|%s|选项 ID \"%s\"}}"):format(action_map[log_data.action], color_code, log_data.id))
        end
        -- ⮑
        if details then
            if log_data.diff then
                local diff = log_data.diff
                if diff.old == "" then
                    msgs(("⮑ 新增章节: <code style=\"color: green\">%s</code>"):format(diff.new))
                else
                    msgs(("⮑ 章节名称: <s  style=\"color: gray\">%s</s> → <code style=\"color: green\">%s</code>"
                        ):
                        format(diff.old, diff.new))
                end
            end
            if details.data and details.data.options then
                msgs(("⮑ 选项数据: <code>%s</code>"):format(mw.text.jsonEncode(details.data.options)))
            end
            if details.id_changed then
                if append_new_mode then
                    msgs(("⮑ 新选项 ID: <code>%s</code>"):format(details.data.id))
                else
                    msgs(("⮑ 选项 ID: <s style=\"color: gray\">%s</s> → <code>%s</code>"):format(log_data.id,
                        details.data.id))
                end
            end
            if details.error then
                msgs(("⮑ 错误原因: %s <code>%s</code>"):format(err_code[details.code], details.code))
                msgs(("⮑ 选项 ID 列表: <code>%s</code>"):format(table.concat(self:get_ids(true), ", ")))
                msgs(("⮑ 完整线路数据: <code>%s</code>"):format(mw.text.jsonEncode(self:to_table())))
            elseif details.id_list and #details.id_list > 0 then
                msgs(("⮑ 选项 ID 列表: <code>%s</code>"):format(table.concat(details.id_list, ", ")))
            end
        end
    end
    return ("<div><h4>`%s` 的路线操作记录</h4>%s</div>"):format(character_name, table.concat(msgs, "<br>"))
end

---@return table
function RouteOptions:to_table()
    local res = {}
    for _, route_opts in ipairs(self) do
        table.insert(res, route_opts)
    end
    return res
end

---@param chapters table
---@param diff table
---@param special_case boolean
---@return boolean, table | string
function RouteOptions:edit(chapters, diff, special_case)
    for diff_idx, diff_info in ipairs(diff) do
        local action = {
            delete = diff_info.action:find("^d"),
            replace = diff_info.action:find("^r"),
            insert = diff_info.action:find("^i"),
            append = diff_info.action:find("^a")
        }
        --region Field requirement checks
        if (-- Field `options` is required for the following:
            -- Case `insert`, `append` in `actions`
            (action.insert or action.append)
                -- Special case `route_opts` (Except `delete`)
                or (special_case and not action.delete)
            ) and not diff_info.options then
            --Err: "Field `options` is required for the following: insert, append, route_opts"
            return false,
                ("单个路线选项 `options` 在 %s 为必填"):format(
                    special_case and "自定义路线 `route_opts`" or "插入/追加操作 `actions`"
                )
        elseif (-- Field `chapter` is required for the following:
            -- Case `append` to the end in `actions`
            (action.append and diff_info.id == "")
                -- Special case `route_opts` (Except `delete`)
                or (special_case and not action.delete)
            ) and not diff_info.chapter then
            --Err: "Field `chapter` is required for the following: append, route_opts"
            return false, ("章节名称 `chapter` 在 %s 为必填"):format(
                special_case and "自定义路线 `route_opts`" or "追加操作 `actions`")
        elseif (-- Field `id` is required for the following:
            -- Case that is other than `append` in `actions`
            not action.append and diff_info.id == ""
            ) then
            --Err: "Field `id` is required for the following: delete, replace, insert, route_opts"
            return false, ("路线 ID `id` 在 %s 不能为空字符。"):format(
                special_case and "自定义路线 `route_opts`" or "删除/替换/插入操作 `actions`"
            )
        elseif (-- One of field `chapter` or `options` must be set:
            -- Case that is other than `delete` in `actions`
            not action.delete
            ) and not (-- Field `chapter` and `options` isn't set
            diff_info.chapter or diff_info.options) then
            --Err: "One of field `chapter` or `options` must be set"
            return false, "路线选项 `chapter` 和 `options` 在 追加/替换/插入操作 `actions` 不能同时为空。"
        end
        --endregion
        -- Edit route option
        local route_id = diff_info.id
        if action.delete and not self:remove(route_id) then
            --Err: "Failed to delete route option"
            return false, ("删除路线选项 `%s` 失败"):format(route_id)
        end
        -- Pre-check for chapter
        local idx
        if (action.replace or action.insert or (action.append and route_id ~= "")) and diff_info.chapter then
            idx = self:get(route_id, true)
            if type(idx) ~= "number" then
                --Err: "Failed to replace route option due to route ID not exist"
                for action_name, cond in pairs(action) do
                    if cond then
                        self:log_route_not_found(action_name, route_id)
                    end
                end
                return false, ("修改章节名称 `%s` 失败,路线 ID 不存在"):format(route_id)
            end
        end
        if action.replace then
            if diff_info.options and not self:replace(route_id, diff_info.options) then
                --Err: "Failed to replace route option"
                return false, ("替换路线选项 `%s` 失败"):format(route_id)
            elseif diff_info.chapter then
                chapters[idx] = diff_info.chapter
            end
        elseif action.insert then
            if diff_info.options and not self:insert(route_id, diff_info.options) then
                --Err: "Failed to insert route option"
                return false, ("插入路线选项 `%s` 失败"):format(route_id)
            elseif diff_info.chapter then
                table.insert(chapters, idx, diff_info.chapter)
            end
        elseif action.append then
            if diff_info.options and not self:append(route_id, diff_info.options) then
                --Err: "Failed to append route option"
                return false, ("追加路线选项 `%s` 失败"):format(route_id)
            elseif diff_info.chapter then
                if route_id == "" then
                    idx = #chapters
                end
                table.insert(chapters, idx + 1, diff_info.chapter)
            end
        end
        self:log_chapter(route_id, diff_info.chapter)
    end
    return true, self:to_table()
end

--endregion

local function get_default_schema()
    ---@type table<string, table>
    local json_schemas = {
        route_opts = {
            _ = "路线列表",
            type = "array",
            required = true,
            items = {
                _ = "路线选项",
                type = "object",
                required = true,
                keys = {
                    id = {
                        _ = "路线选项 ID,用于标注选项并在 `route_edit` 中使用。",
                        type = "string"
                    },
                    options = {
                        _ = "选项列表",
                        type = "array",
                        required = true,
                        faker = 2,
                        items = {
                            type = "string",
                            required = true
                        }
                    }
                }
            }
        },
    }

    ---@type table<string, table>
    local json_schema_data = {
        log = {
            _ = "日志等级",
            type = "string",
            enum = { "none", "debug", "info", "warn", "error", "d", "i", "w", "e" },
            default = "none"
        },
        options = {
            _ = "一般选项",
            type = "object",
            keys = {
                tab_mode = {
                    _ = "Tab 模式 [[Template:Tabs]]",
                    type = "string",
                    enum = { "color", "core" },
                    default = "color"
                },
                tab_opts = {
                    _ = "Tabs 参数,注意不能使用任何 `bn(x)`, `bticon(x)`, `tab(x)`",
                    type = "object",
                    keys = (function(key)
                        if key:find("^bn%d+$") or key:find("^bticon%d+$") or key:find("^tab%d+$") then
                            return false, "`tab_opts` 不能使用任何 `bn(x)`, `bticon(x)`, `tab(x)` 的选项"
                        end
                        return true
                    end)
                },
                line_break = {
                    _ = "是否为每一个路线选项开新行",
                    type = "boolean",
                    default = false
                },
                display_numbers = {
                    _ = "是否为在路线选项前添加数字",
                    type = "boolean",
                    default = false
                }
            }
        },
        chapters = {
            _ = "章节名称",
            type = "array",
            required = true,
            faker = 5,
            items = {
                type = "string",
                required = true
            }
        },
        route_opts = json_schemas.route_opts,
        routes = {
            _ = "角色路线",
            type = "array",
            required = true,
            faker = 3,
            items = {
                type = "object",
                keys = {
                    name = {
                        _ = "角色名称",
                        type = "string",
                        required = true
                    },
                    icon = {
                        _ = "Tab 的图标, core 模式不适用并自动忽略",
                        type = "string",
                        faker = false
                    },
                    route_opts = utils.shallow_copy(json_schemas.route_opts),
                    route_edit = {
                        _ = "为该角色进行路线微调,按列表顺序执行操作",
                        type = "array",
                        items = {
                            --[[
                                The following options are required for the following:
                                    - Case `insert`, `append` in `actions`
                                    - Special case `route_opts`
                            --]]
                            _ = table.concat({
                                "注: 在下列情况,以下选项为必填项:",
                                "* `insert`, `append` 操作时的 `actions`",
                                "* 特殊情况 - 自定义路线"
                            }, "<br>"),
                            type = "object",
                            keys = {
                                action = {
                                    --[[
                                        目前一共有三个操作类型:
                                            - `delete`: 移除
                                            - `insert`: 插入,在指定路线前插入选项
                                            - `append`: 追加,在指定路线后添加选项
                                            - `replace`: 替换特定选项/章节名称
                                    --]]
                                    _ = table.concat({
                                        "操作类型,目前一共有三个操作类型:",
                                        "* `delete`: 移除",
                                        "* `insert`: 插入,在指定路线前插入选项",
                                        "* `append`: 追加,在指定路线后添加选项",
                                        "* `replace`: 替换特定选项/章节名称"

                                    }, "<br>"),
                                    type = "string",
                                    required = true,
                                    enum = { "d", "r", "i", "a", "delete", "replace", "insert", "append" }
                                },
                                id = {
                                    _ = "操作的路线选项 ID,如果操作类型是 `append` 则可以留空表示追加到最后。",
                                    type = "string",
                                    required = true
                                },
                                chapter = {
                                    _ = "章节名称",
                                    type = "string"
                                },
                                options = utils.shallow_copy(json_schemas.route_opts.items)
                            }
                        },
                        faker = false
                    },
                    selects = {
                        _ = "该角色的路线选项,超出路线的选项将会被忽略。",
                        type = "array",
                        required = true,
                        items = {
                            type = "number",
                            default = 0
                        }
                    },
                    note = { -- TODO: Render as wikitext?
                        _ = "角色路线注意事项。",
                        type = "string",
                        faker = 10
                    }
                }
            }
        }
    }

    -- Some dymanic edit

    --TODO
    --路线列表。此为特殊用途,将覆盖原有的 `route_opts` 选项。
    --注:如您想为该角色重新自定义路线则可使用。否则建议使用 `route_edit` 替代。
    json_schema_data.routes.items.keys.route_opts._ = table.concat({
        "路线列表。此为特殊用途,将覆盖原有的 `route_opts` 选项。",
        "注: 除非您想为该角色重新自定义路线,否则建议使用 `route_edit` 替代。"
    }, "<br>")
    json_schema_data.routes.items.keys.route_opts.required = nil
    json_schema_data.routes.items.keys.route_opts.faker = false
    json_schema_data.routes.items.keys.route_edit.items.keys.options.required = nil
    json_schema_data.routes.items.keys.route_edit.items.keys.options.faker = false


    return json_schema_data
end

local create_elem = mw.html.create
log = log:new()

---This message is shown to end user, and tells the editor to check for error messages.
local function gen_error_msg_user(frame)
    return frame:expandTemplate {
        title = "color",
        args = {
            "red",
            "<i>[表格生成失败,请打开编辑模式查看错误信息。]</i>"
        }
    }
end

return {
    main = function()
        local msgs = {
            "<h4 style=\"padding-bottom: 0; margin-bottom: 0\"> [[Module:GameRoutes|Game Routes 模块帮助]] </h4>",
            "此函数 `main` 用于展示模块介绍,以下函数可用:",
            "<li> `gen`: 生成表格。</li>",
            "<li> `docs`: 生成本模块的 JSON Schema 架构文档。</li>",
            "<li> `test`: (模块开发者用) 模块单元测试</li>",
            "详细信息及使用方法参见 [[Module:GameRoutes]]",
            "<br>",
            "如有任何问题请在讨论页留言"
        }
        mw.addWarning(("<div><span style=\"color: black\">%s</span><hr></div>"):format(table.concat(msgs, "")))
    end,
    gen = function(frame)
        local json_schema = JsonSchema:new(get_default_schema())
        if not json_schema then
            return gen_error_msg_user(frame)
        end
        local res, json = json_schema:parse_data(frame.args[1])
        if not res then
            log:error(json)
            return gen_error_msg_user(frame)
        elseif not json then
            return
        end

        -- Setup logging level
        log:set_level(json.log)

        local options = json.options or {}

        -- Setup Tabs
        local tab_key_name, tab_core_mode = "tabs", options.tab_mode == "core"
        local tab_label_key, tab_content_key = "bt", "tab"
        if tab_core_mode then
            tab_label_key, tab_content_key = "label", "text"
            tab_key_name = tab_key_name .. "/core"
        end

        -- Other options
        local line_break, display_numbers = utils.tobool(options.line_break), utils.tobool(options.display_numbers)

        --region Tab generating
        local tabs, tab_num = {}, 1

        -- Main tab
        tabs[("%s%d"):format(tab_label_key, tab_num)] = "序言"
        tabs[("%s%d"):format(tab_content_key, tab_num)] = ("%s%s"):format("{{剧透提醒|align=left|width=}}",
            type(frame.args[2]) == "string" and frame.args[2] or "")
        tab_num = tab_num + 1

        -- Route tabs
        for _, route in ipairs(json.routes) do -- In character
            ---@diagnostic disable-next-line: redefined-local
            local root_div = create_elem("div")
            local custom_route = utils.tobool(route.route_opts)
            local route_opts = table.clone(route.route_opts or json.route_opts)
            local chapters = custom_route and {} or table.clone(json.chapters)

            -- Edit route by actions
            if route.route_edit and #route.route_edit > 0 then
                log:debug("Number of diffs:", #route.route_edit)
                local route_opt = RouteOptions:new(route_opts)
                local res, new_route_opts = route_opt:edit(chapters, route.route_edit, custom_route)
                if not res then
                    log:error(new_route_opts)
                    if #route_opt.action_log > 0 and log.level then
                        mw.addWarning(("<span style=\"color: black\">%s</span>"):format(route_opt:format_log(route.name)))
                    end
                    return gen_error_msg_user(frame)
                end
                assert(type(new_route_opts) == "table",
                    ("New route options should be a table, but got %s"):format(type(route_opts)))
                route_opts = new_route_opts
                log:debug("Updated:")
                log:debug("Route options", route_opts)
            end

            -- Type check
            assert(type(route_opts) == "table", ("Route options should be a table, but got %s"):format(type(route_opts)))

            -- Generate tabs content
            for chapter_idx in ipairs(route_opts) do -- In chapter
                if chapter_idx > 1 then
                    root_div:node("<br>")
                end
                local div_elem = create_elem("div")
                if not chapters[chapter_idx] then
                    log:warn(("缺失章节 `%d` 的名称"):format(chapter_idx))
                end
                local chapter_span = create_elem("span"):wikitext(("○ %s"):format(chapters[chapter_idx] or "?"))
                div_elem:node(chapter_span):node("<br>")
                local chapter_options = route_opts[chapter_idx].options
                for opt_idx, option_name in ipairs(chapter_options) do -- In options
                    local option_span = create_elem("span")
                    local text = option_name
                    if display_numbers then
                        text = ("%d %s"):format(opt_idx, text)
                    end
                    option_span:wikitext(text)

                    if route.selects[chapter_idx] == opt_idx then
                        option_span:css("color", "red")
                    else
                        option_span:css("color", "gray")
                    end

                    div_elem:node(option_span)

                    if opt_idx ~= #chapter_options then
                        div_elem:node(line_break and "<br>" or "&nbsp;")
                    elseif opt_idx == #chapter_options then
                        root_div:node(div_elem)
                    end
                end
            end

            -- Add notes
            if route.note and route.note ~= "" then
                root_div:node("<br><hr>")
                    :node(create_elem("p"):wikitext(route.note))
            end

            -- Add tab to tab_opts
            log:debug(("Adding tab `%s` as tab%d"):format(route.name, tab_num))
            tabs[("%s%d"):format(tab_label_key, tab_num)] = ("%s%s"):format(
                not tab_core_mode and route.icon and "&nbsp;" or "", route.name
            )
            if not tab_core_mode and route.icon then
                tabs[("bticon%d"):format(tab_num)] = route.icon
            end
            tabs[("%s%d"):format(tab_content_key, tab_num)] = tostring(root_div)
            tab_num = tab_num + 1
        end

        --endregion

        -- Known issue: frame:expandTemplate can't be use becuase table will fuck up the order (Lua LOL)
        -- Replaced with frame:preprocess (Slow)

        local template, tab_keys = tab_key_name, {}

        for k in pairs(tabs) do
            tab_keys[#tab_keys + 1] = k
        end

        table.sort(tab_keys, function(a, b)
            for _, pattern in ipairs(utils.patterns) do
                local a1, b1 = a:match(pattern), b:match(pattern)
                if a1 and b1 then
                    return tonumber(a1) < tonumber(b1)
                end
            end
            return a < b
        end)

        for k, v in pairs(options.tab_opts) do
            template = ("%s|%s = %s"):format(template, k, v)
        end

        for _, k in ipairs(tab_keys) do
            template = ("%s|%s = %s"):format(template, k, tabs[k])
        end

        return frame:preprocess(("{{%s}}"):format(template))
    end,
    docs = function(frame)
        local json_schema = JsonSchema:new(get_default_schema())
        if not json_schema then
            return gen_error_msg_user(frame)
        end
        return frame:preprocess(json_schema:gen_docs())
    end,
    ---@diagnostic disable: param-type-mismatch
    test = function(frame)
        log:set_level("d")
        local schema = JsonSchema:new(get_default_schema())
        if not schema then
            return log:error("JSON Schema verification failed, abort unit test.")
        end

        ---@param test_case string
        ---@param func function
        ---@return boolean
        local describe = function(test_case, func)
            log:debug(("❓ Test case: %s"):format(test_case))
            ---@param what string
            ---@param callback function
            return func(function(what, callback)
                local res, err = pcall(callback)
                if not res then
                    log:error(("❌ Test case failed, expected it %s.\nStack trace:\n%s"):format(what, err))
                else
                    log:debug(("✅ Test case passed, it %s as expected."):format(what))
                end
                return res
            end)
        end
        log:info("⚙ Schema loaded, start unit test")
        if not describe("Data Parsing", function(it)
            if not it("should failed the parsing", function()
                assert(not schema:parse_data("{"))
                assert(not schema:parse_data(true))
                assert(not schema:parse_data("null"))
            end) then return end
            if not it("should pass the parsing becuase of empty data", function()
                assert(schema:parse_data(nil))
                assert(schema:parse_data("{}"))
                assert(schema:parse_data("[]"))
                assert(schema:parse_data(""))
            end) then return end
            return true
        end) then return end
        if not describe("Data Verification", function(it)
            if not it("should failed the schema verify", function()
                assert(type(schema:verify_data({})) == "string")
            end) then return end
            if not it("should pass the verify", function()
                local res = schema:verify_data({
                    chapters   = { Faker:full_name() },
                    route_opts = {
                        {
                            options = {
                                Faker:full_name(),
                                Faker:full_name()
                            }
                        }
                    },
                    routes     = {
                        {
                            name = Faker:gen_name(1),
                            selects = { 1 }
                        }
                    }
                })
                assert(type(res) == "table", res)
            end) then return end
            return true
        end) then return end
        -- TODO: Add generate test
        log:info("🚀 Unit test passed")
    end
}