-- parstat.opm
-- Copyright 2025-2026 Petr Krajník
--
-- This work may be distributed and/or modified under the
-- conditions of the LaTeX Project Public License, either version 1.3c
-- of this license or (at your option) any later version.
-- The latest version of this license is in
--   https://www.latex-project.org/lppl.txt
-- and version 1.3c or later is part of all distributions of LaTeX
-- version 2008 or later.
--
-- This work has the LPPL maintenance status 'maintained'.
--
-- The Current Maintainer of this work is Petr Krajník.
--
-- This work consists of the files parstat.opm and parstat.lua.
--

local PKG_PREFIX = "_parstat_"

-- Register names
local REG_ENABLED = PKG_PREFIX .. "enabled"
local REG_VERBOSE = PKG_PREFIX .. "verbose"
local REG_FSKIP   = PKG_PREFIX .. "fskip"
local REG_BSKIP   = PKG_PREFIX .. "bskip"

-- Node types
local NODE_HLIST = node.id("hlist")
local NODE_VLIST = node.id("vlist")
local NODE_GLYPH = node.id("glyph")
local NODE_GLUE  = node.id("glue")

-- Node subtypes
local GLYPH_LIGATURE_FLAG = 2
local GLYPH_GHOST_FLAG = 4
local GLUE_SPACE = 13
local GLUE_XSPACE = 14

-- Line statistic outputs per console line
local STATS_PER_LINE = 5
assert(STATS_PER_LINE > 0, "STATS_PER_LINE must be greater than zero")

-- Line skips (only valid when processing)
local front_skip, back_skip

local function init_line_skips()
    front_skip = math.max(0, tex.count[REG_FSKIP])
    back_skip  = math.max(0, tex.count[REG_BSKIP])
end

-- Statistic structure
local function stat_new(name)
    return {
        name = name,
        samples = 0, -- Equals to line count
        average = 0,
        M2 = 0, -- Sum of squared diffs for stddev
        min = math.huge,
        max = 0 }
end

local function make_stats()
    return stat_new("Glyphs"), stat_new("Spaces")
end

local function stat_add(stat, value)
    stat.samples = stat.samples + 1

    -- Average and stddev (Welford’s Algorithm)
    local delta_old = value - stat.average
    stat.average = stat.average + delta_old / stat.samples
    local delta_new = value - stat.average
    stat.M2 = stat.M2 + delta_old * delta_new

    stat.min = math.min(stat.min, value)
    stat.max = math.max(stat.max, value)
end

local function stat_stddev(stat)
    if stat.samples > 1 then
        return math.sqrt(stat.M2 / (stat.samples - 1))
    end
    return 0.0
end

local function stat_min(stat)
    if stat.min == math.huge then
        return 0
    end
    return stat.min
end

-- Document statistics
local function doc_stat_new()
    local g, s = make_stats()
    return {
        glyphs = g,
        spaces = s,
        par_count = 0
    }
end

local doc_stat = doc_stat_new()

-- Logging
local function log_write_nl(msg)
    texio.write_nl(msg)
end

local function log_writef_nl(fmt, ...)
    assert(select('#', ...) > 0)
    texio.write_nl(string.format(fmt, ...))
end

local function log_writef(fmt, ...)
    assert(select('#', ...) > 0)
    texio.write(string.format(fmt, ...))
end

local function log_header(what)
    log_writef_nl("Parstat %s from %s:%d",
        tostring(what), status.filename, status.linenumber)
end

local function log_stat(stat)
    log_writef_nl("  %s: ave %.2f, stddev %.2f, min %d, max %d",
        stat.name, stat.average, stat_stddev(stat), stat_min(stat), stat.max)
end

local function log_line_stat(line, line_ignored, glyph_count, space_count)
    if (line - 1) % STATS_PER_LINE == 0 then
        log_write_nl(" ") -- Limit line stats per line
    end
    log_writef(" %d%s:(%dg,%ds)",
        line, line_ignored and "X" or "", glyph_count, space_count)
end

local function log_newline()
    texio.write_nl("")
end

--------------------------------

local function enough_lines(line_count)
    return (front_skip + back_skip) < line_count
end

local function is_line_ignored(line, line_count)
    return line <= front_skip or line > (line_count - back_skip)
end

local function is_real_glyph(n)
    return n.id == NODE_GLYPH and (n.subtype & GLYPH_GHOST_FLAG) == 0
end

local function is_ligature(n)
    return (n.subtype & GLYPH_LIGATURE_FLAG) ~= 0
end

local function is_space(n)
    return n.id == NODE_GLUE and (n.subtype == GLUE_SPACE
        or n.subtype == GLUE_XSPACE)
end

local function is_nested_node(n)
    return n.id == NODE_HLIST or n.id == NODE_VLIST
end

local function count_glyphs(head)
    local glyph_count = 0
    local space_count = 0

    for n in node.traverse(head) do
        if is_real_glyph(n) then
            if is_ligature(n) and n.components then
                -- Count Ligature components (see limitations)
                local gc, sc =  count_glyphs(n.components)
                glyph_count = glyph_count + gc
                space_count = space_count + sc
            else
                glyph_count = glyph_count + 1
            end
        elseif is_space(n) then
            glyph_count = glyph_count + 1
            space_count = space_count + 1
        elseif is_nested_node(n) then
            local gc, sc =  count_glyphs(n.head)
            glyph_count = glyph_count + gc
            space_count = space_count + sc
        end
    end
    return glyph_count, space_count
end

local function analyze_par(head, line_count, verbose)
    local glyph_stat, space_stat = make_stats()
    local curr_line = 0

    for line in node.traverse_id(NODE_HLIST, head) do
        curr_line = curr_line + 1
        local line_ignored = is_line_ignored(curr_line, line_count)

        -- Single line stat
        local glyph_count, space_count = count_glyphs(line.head)
        if verbose >= 2 then
            log_line_stat(curr_line, line_ignored, glyph_count, space_count)
        end

        if not line_ignored then
            -- Add the line results to the stats
            stat_add(glyph_stat, glyph_count)
            stat_add(space_stat, space_count)
            stat_add(doc_stat.glyphs, glyph_count)
            stat_add(doc_stat.spaces, space_count)
        end
    end
    assert(curr_line == line_count)

    if verbose > 0 then
        log_stat(glyph_stat)
        log_stat(space_stat)
        log_newline()
    end
end

--------------------------------

local parstat = _ENV.parstat or {}
_ENV.parstat = parstat

function parstat.run(head)
    if tex.count[REG_ENABLED] > 0 then
        init_line_skips()
        local line_count = node.count(NODE_HLIST, head)
        local verbose = tex.count[REG_VERBOSE]

        if enough_lines(line_count) then
            doc_stat.par_count = doc_stat.par_count + 1
            if verbose > 0 then
                log_header(doc_stat.par_count)
            end
            analyze_par(head, line_count, verbose)
        end
    end
    return head
end

function parstat.summary()
    log_header("summary")
    log_writef_nl("  Analyzed %d paragraphs, %d lines in total",
        doc_stat.par_count, doc_stat.glyphs.samples)
    log_stat(doc_stat.glyphs)
    log_stat(doc_stat.spaces)
    log_newline()
end

function parstat.reset()
    log_header("reset")
    log_newline()

    doc_stat = doc_stat_new()
end
