-- pkgmeta.lua
--
-- Lua backend for the pkgmeta package.
--
-- Copyright (C) 2026 Amer Amikhteh
--
-- This file is part of the pkgmeta package.
--
-- This work is free software: you may redistribute it and/or modify
-- it under the terms of the LaTeX Project Public License as published
-- by the LaTeX Project, either version 1.3c of the License, or
-- (at your option) any later version.
--
-- This work is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
--
-- The full license text is available at:
-- https://www.latex-project.org/lppl/
--
-- This work is "maintained" by Amer Amikhteh.
--
-- The current maintainer of this work is Amer Amikhteh.
--
-- Version: 1.0
-- Date: 2026-09-29


local M = {}



local function trim(value)

    if not value then

        return nil

    end

    return value:match("^%s*(.-)%s*$")

end



local function texescape(value)

    local s = tostring(value or "")

    local replacements = {

        ["\\"] = "\\textbackslash{}",
        ["{"]  = "\\{",
        ["}"]  = "\\}",
        ["$"]  = "\\$",
        ["&"]  = "\\&",
        ["#"]  = "\\#",
        ["^"]  = "\\textasciicircum{}",
        ["_"]  = "\\_",
        ["%"]  = "\\%",
        ["~"]  = "\\textasciitilde{}"

    }

    return (s:gsub("[\\{}$&#^_%%~]", replacements))

end



local function readfile(path)

    local file = io.open(path, "rb")

    if not file then

        return nil

    end

    local contents = file:read("*a")

    file:close()

    return contents

end



local function without_comments(contents)

    local result = {}

    for line in (contents .. "\n"):gmatch("(.-)\n") do

        local pieces = {}

        local i = 1

        while i <= #line do

            local ch = line:sub(i, i)

            if ch == "%" then

                local preceding = 0

                local j = i - 1

                while j > 0 and line:sub(j, j) == "\\" do

                    preceding = preceding + 1
                    j = j - 1

                end

                if preceding % 2 == 0 then

                    break

                end

            end

            pieces[#pieces + 1] = ch
            i = i + 1

        end

        result[#result + 1] = table.concat(pieces)

    end

    return table.concat(result, "\n")

end



local function resolve_macro(contents, expression)

    local name =
        expression:match("^%s*\\([%a@:_]+)%s*$")

    if not name then

        return nil

    end



    -- Escape only Lua pattern metacharacters.
    -- @, :, and _ are ordinary characters.

    local escaped_name =
        name:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")



    local definition =
        contents:match(
            "\\def%s*\\" ..
            escaped_name ..
            "%s*{([^{}]*)}"
        )
        or
        contents:match(
            "\\gdef%s*\\" ..
            escaped_name ..
            "%s*{([^{}]*)}"
        )
        or
        contents:match(
            "\\edef%s*\\" ..
            escaped_name ..
            "%s*{([^{}]*)}"
        )
        or
        contents:match(
            "\\newcommand%s*{%s*\\" ..
            escaped_name ..
            "%s*}%s*{([^{}]*)}"
        )
        or
        contents:match(
            "\\newcommand%s*%*\\" ..
            escaped_name ..
            "%s*{([^{}]*)}"
        )



    if not definition then

        return nil

    end



    -- Resolve nested macros.

    for _ = 1, 10 do

        local changed = false

        definition =
            definition:gsub(
                "\\[%a@:_]+",
                function(macro)

                    if macro == "\\space" then
                        changed = true
                        return " "
                    end

                    local value =
                        resolve_macro(contents, macro)

                    if value then
                        changed = true
                        return value
                    end

                    return macro

                end
            )

        if not changed then
            break
        end

    end

    return definition

end



local function resolve_macros(contents, value)

    for _ = 1, 10 do

        local changed = false

        value =
            value:gsub(
                "\\[%a@:_]+",
                function(macro)

                    if macro == "\\space" then
                        changed = true
                        return " "
                    end

                    local resolved =
                        resolve_macro(contents, macro)

                    if resolved then
                        changed = true
                        return resolved
                    end

                    return macro

                end
            )

        if not changed then
            break
        end

    end

    return value

end



local function extract_description(full, date, version)

    if not full or not date or not version then

        return nil

    end



    local position =
        full:find(date, 1, true)

    if not position then

        return nil

    end



    local remainder =
        full:sub(position + #date)



    remainder =
        trim(remainder)



    if remainder:sub(1, 1) == "v" then

        remainder =
            remainder:sub(2)

    end



    if remainder:sub(1, #version) == version then

        remainder =
            remainder:sub(#version + 1)

    end



    return trim(remainder)

end

local function remove_leading_v(value)

    value = trim(value)

    if value and value:match("^v") then
        return value:sub(2)
    end

    return value

end


local function extract(contents)

    local source =
        without_comments(contents)



    -- ========================================================
    -- \ProvidesExplPackage
    -- ========================================================

    local date, version, description =
        source:match(
            "\\ProvidesExplPackage%s*{[^{}]*}%s*{([^{}]*)}%s*{([^{}]*)}%s*{([^{}]*)}"
        )

    if date then
        date = trim(date)
        version = remove_leading_v(version)
        description = trim(description)

        local year, month, day =
            date:match("^(%d%d%d%d)[/-](%d%d)[/-](%d%d)$")

        return {
            full        = date .. " " .. version .. " " .. description,
            date        = date,
            version     = version,
            year        = year,
            month       = month,
            day         = day,
            description = description
        }
    end

    -- ========================================================
    -- \ProvidesExplFile
    -- ========================================================

    date, version, description =
        source:match(
            "\\ProvidesExplFile%s*{[^{}]*}%s*{([^{}]*)}%s*{([^{}]*)}%s*{([^{}]*)}"
        )

    if date then
        date = trim(date)
        version = remove_leading_v(version)
        description = trim(description)

        local year, month, day =
            date:match("^(%d%d%d%d)[/-](%d%d)[/-](%d%d)$")

        return {
            full        = date .. " " .. version .. " " .. description,
            date        = date,
            version     = version,
            year        = year,
            month       = month,
            day         = day,
            description = description
        }
    end


    -- ========================================================
    -- \ProvidesPackage / \ProvidesFile
    -- ========================================================

    local full =
        source:match(
            "\\ProvidesPackage%s*{[^{}]*}%s*%[([^%]]*)%]"
        )
        or
        source:match(
            "\\ProvidesFile%s*{[^{}]*}%s*%[([^%]]*)%]"
        )



    if not full then

        return nil

    end



    -- ========================================================
    -- Date
    -- ========================================================

    date =
        full:match(
            "([0-9][0-9][0-9][0-9][/-][0-9][0-9][/-][0-9][0-9])"
        )

    date = trim(date)



    -- ========================================================
    -- Version
    -- ========================================================

    local version



    if date then

        local position =
            full:find(date, 1, true)

        local remainder =
            full:sub(position + #date)



        version =
            remainder:match(
                "^%s*v([0-9][%w%.%-]*)"
            )
            or
            remainder:match(
                "^%s*([0-9][%w%.%-]*)"
            )



        version = trim(version)

    end



    -- ========================================================
    -- Date macro
    -- ========================================================

    if not date then

        local date_macro =
            full:match(
                "(\\[%a@:_]*date[%a@:_]*)"
            )



        if date_macro then

            date =
                resolve_macro(
                    source,
                    date_macro
                )



            date = trim(date)

        end

    end



    -- ========================================================
    -- Version macro
    -- ========================================================

    if not version then

        local version_macro =
            full:match(
                "(\\[%a@:_]*version[%a@:_]*)"
            )



        if version_macro then

            version =
                resolve_macro(
                    source,
                    version_macro
                )

        else

            version =
                full:match(
                    "v([0-9][%w%.%-]*)"
                )

        end



        version = trim(version)

    end



    -- ========================================================
    -- Date components
    -- ========================================================

    local year
    local month
    local day



    if date then

        year, month, day =
            date:match(
                "^(%d%d%d%d)[/-](%d%d)[/-](%d%d)"
            )

    end



    return {

        full        = trim(full),
        date        = trim(date),
        version     = trim(version),
        year        = trim(year),
        month       = trim(month),
        day         = trim(day),
        description = nil

    }

end



function M.get(pkg, key)

    pkg =
        tostring(pkg)
        :gsub("^%s+", "")
        :gsub("%s+$", "")



    key = key or "default"



    local filename



   local filename

-- If the name has no extension, use .sty by default.
-- Otherwise, preserve the extension supplied by the user.
if pkg:match("%.[^./\\]+$") then
    filename = pkg
else
    filename = pkg .. ".sty"
end




    local path =
        kpse.find_file(filename, "tex")



    if not path then

        return "unresolved"

    end



    local contents =
        readfile(path)



    if not contents then

        return "unresolved"

    end



    local data =
        extract(contents)



    if not data then

        return "unresolved"

    end



    -- Resolve macros contained in the complete
    -- \ProvidesPackage/\ProvidesFile information.

    data.full =
        trim(
            resolve_macros(
                contents,
                data.full
            )
        )



    -- Extract the description from the
    -- fully resolved information.

    data.description =
        extract_description(
            data.full,
            data.date,
            data.version
        )



    local value



    if key == "default" then

        value = data.full

    elseif key == "version" then

        value = data.version

    elseif key == "date" then

        value = data.date

    elseif key == "year" then

        value = data.year

    elseif key == "month" then

        value = data.month

    elseif key == "day" then

        value = data.day

    elseif key == "description" then

        value = data.description

    else

        value = "unresolved"

    end



    return texescape(
        value or "unresolved"
    )

end



function M.print(pkg, key)

    tex.sprint(
        M.get(pkg, key)
    )

end



return M