--[[ Apply optional formatting and translation to a number.
For example, an infobox may accept numbers in international or
local digits, but processing may require international digits.
The result may be wanted in international or local digits, and
may be formatted by grouping digits.
]]

local MINUS = '−'  -- Unicode U+2212 MINUS SIGN (UTF-8: e2 88 92)
local ustring = mw.ustring
local unknown_error = 'अज्ञात त्रुटि'

local mtext_ne = {
    -- Module text: input parameter names/values, and output messages.
    -- The name on the left of "=" is the name used in this module.
    -- The text in single quotes is input used in the template, or
    -- is output text displayed by the result.
    parm_type = 'प्रकार',      -- parameter to specify wanted type of output
    type_int = 'अरबी',        -- ...output in international digits
    type_dev = 'नागरी',        -- ...output in devanagari digits
    parm_group = 'स्वरूपण',    -- parameter to specify wanted number grouping in output
    grp_int = 'अंतर्राष्ट्रीय',    -- ...three-digit groups
    grp_dev = 'भारतीय',        -- ...three-then-two-digit groups
    parm_numdot = 'decimal', -- parameter to specify decimal mark (single byte; default dot)
    parm_numsep = 'sep',     -- parameter to specify group separator (single byte; default comma)
    -- Output messages ("%s" is replaced with the invalid input text).
    invalid_number = 'त्रुटि: "%s" मान्य अंक होइन।',
    invalid_parm = 'त्रुटि: "%s" प्राचल (पैरामीटर)मा दिएको जानकारी "%s" अमान्य ।',
    missing_parm = 'त्रुटि: कृपया संख्या दिनुहोस्।',
}

local mtext_en = {
    -- English text for testing.
    parm_type = 'type',
    type_int = 'int',
    type_dev = 'dev',
    parm_group = 'format',
    grp_int = 'int',
    grp_dev = 'dev',
    parm_numdot = 'decimal',
    parm_numsep = 'sep',
    invalid_number = 'Value "%s" is not a valid number',
    invalid_parm = 'Parameter "%s" has invalid value "%s"',
    missing_parm = 'Need value',
}

local from_international_table = {
    ['0'] = '०',
    ['1'] = '१',
    ['2'] = '२',
    ['3'] = '३',
    ['4'] = '४',
    ['5'] = '५',
    ['6'] = '६',
    ['7'] = '७',
    ['8'] = '८',
    ['9'] = '९',
}

local to_international_table = {
    ['०'] = '0',
    ['१'] = '1',
    ['२'] = '2',
    ['३'] = '3',
    ['४'] = '4',
    ['५'] = '5',
    ['६'] = '6',
    ['७'] = '7',
    ['८'] = '8',
    ['९'] = '9',
}

local function collection()
    -- Return a table to hold items.
    return {
        n = 0,
        add = function (self, item)
            self.n = self.n + 1
            self[self.n] = item
        end,
    }
end

local function empty(text)
    -- Return true if text is nil or empty (assuming a string).
    return text == nil or text == ''
end

local function strip(text)
    -- If text is a string, return its content with no leading/trailing
    -- whitespace. Otherwise return nil (a nil argument gives a nil result).
    if type(text) == 'string' then
        return text:match("^%s*(.-)%s*$")
    end
end
local function strip_to_nil(text)
    -- Return stripped text or nil if empty.
    if text ~= nil then
        text = strip(text)
        if text == '' then
           text = nil
        end
    end
    return text
end

local function from_international(parms, text)
    -- Input is a string representing a number in en digits with '.' decimal mark,
    -- without digit grouping (which is done just after calling this).
    -- Return the string with numdot and, if wanted, after translating
    -- each digit to the local language.
    if parms.numdot ~= '.' then
        text = text:gsub('%.', parms.numdot)
    end
    if parms.otype ~= 'int' then
        text = text:gsub('%d', from_international_table)
    end
    return text
end

local function to_international(parms, text)
    -- Input is a string representing a number in the local language with
    -- optional numdot decimal mark and numsep digit grouping.
    -- The input may also use international digits (which are not changed).
    -- Return the translation of the string with '.' mark and en digits,
    -- and no separators (they have to be removed here to handle cases like
    -- numsep = '.' and numdot = ',' with input "1.234.567,8").
    if parms.numsep ~= '' then
        text = text:gsub('[' .. parms.numsep .. ']', '')  -- use '[x]' in case x is '.'
    end
    if parms.numdot ~= '.' then
        text = text:gsub('[' .. parms.numdot .. ']', '.')
    end
    text = ustring.gsub(text, '%d', to_international_table)
    return text
end

local function extract_groups(parms, digits)
    -- Return digits split into groups and translated, if wanted.
    -- Parameter digits is 0..9 only (no sign, no decimal point, no exponent).
    -- Each digit must be a byte because am not using mw.ustring functions.
    local length = #digits
    local places = collection()
    local pos, step = 0, 3
    while pos < length do
        places:add(pos)
        pos = pos + step
        if parms.grouping == 'dev' then
            step = 2
        end
    end
    places:add(length)
    local groups = collection()
    for i = places.n, 2, -1 do
        local p1 = length - places[i] + 1
        local p2 = length - places[i - 1]
        groups:add(from_international(parms, digits:sub(p1, p2)))
    end
    return groups
end

local function with_separator(parms, text)
    -- Return text with group separators inserted, if wanted.
    -- Input uses international digits.
    -- Output optionally uses digits in local language.
    -- The given text is like '123' or '12345.6789' or '1.23e45'.
    -- The text has no sign (caller inserts that later, if necessary).
    -- Separator is inserted only in the integer part of the significand
    -- (not after numdot, and not after 'e' or 'E').
    if parms.grouping == nil or parms.numsep == '' then
        return from_international(parms, text)
    end
    local last = text:match('()[.eE]')  -- () returns position
    if last == nil then
        last = #text
    else
        last = last - 1  -- index of last character before dot/e/E
    end
    if last < 4 or (last == 4 and parms.opt_comma5) then
        return from_international(parms, text)
    end
    local groups = extract_groups(parms, text:sub(1, last))
    return table.concat(groups, parms.numsep) .. from_international(parms, text:sub(last+1))
end

local function get_parms(args)
    -- Return a table of parameter names and values, using terms known in this module.
    -- Input numstr is converted to international digits with no formatting.
    -- Input padlen is converted to a number (or nil if no padding).
    -- Throw an error if input is invalid.
    local parms = {}
    local mtext = (args.lang == 'en') and mtext_en or mtext_ne
    local function die(code, parm1, parm2)
        error(string.format(mtext[code] or unknown_error, parm1, parm2), 0)
    end
    local numstr = strip_to_nil(args[1])
    if not numstr then
        die('missing_parm')
    end
    local otype = strip_to_nil(args[mtext.parm_type])
    if otype then
        if otype == mtext.type_int then
            otype = 'int'
        elseif otype == mtext.type_dev then
            otype = 'dev'
        else
            die('invalid_parm', mtext.parm_type, otype)
        end
    else
        -- Output type is opposite of input type.
        -- Assume finding any type_int digit means input is type_int.
        otype = numstr:find('%d') and 'dev' or 'int'
    end
    parms.otype = otype
    local grp = strip_to_nil(args[mtext.parm_group])
    if grp then
        if grp == mtext.grp_int then
            grp = 'int'
        elseif grp == mtext.grp_dev then
            grp = 'dev'
        else
            die('invalid_parm', mtext.parm_group, grp)
        end
    end
    parms.grouping = grp  -- nil is no grouping
    local dot = strip_to_nil(args[mtext.parm_numdot])
    if dot then
        if not (dot == '.' or dot == ',') then
            die('invalid_parm', mtext.parm_numdot, dot)
        end
    else
        dot = '.'
    end
    parms.numdot = dot
    local sep = strip_to_nil(args[mtext.parm_numsep])
    if sep then
        if not (sep == '.' or sep == ',') then
            die('invalid_parm', mtext.parm_numsep, sep)
        end
    else
        sep = ','
    end
    parms.numsep = sep
    -- Clean input number and remove any sign.
    local clean = to_international(parms, numstr)
    local sign
    for _, prefix in ipairs({ '+', '-', MINUS }) do
        local plen = #prefix
        if clean:sub(1, plen) == prefix then
            if sign then  -- more than one sign
                die('invalid_number', numstr)
            end
            sign = (prefix == '+') and '+' or MINUS
            clean = strip(clean:sub(plen + 1))
        end
    end
    parms.sign = sign or ''
    if tonumber(clean) then
        -- Number is valid, but omit any trailing '.' as redundant.
        if clean:sub(-1) == '.' then
            clean = clean:sub(1, -2)
        end
        parms.numstr = clean
    else
        die('invalid_number', numstr)
    end
    local padlen = strip_to_nil(args[2])
    if padlen then
        local value = tonumber(to_international(parms, padlen))
        if value then
            parms.padlen = value
        else
            die('invalid_number', padlen)
        end
    end
    return parms
end

local function do_number(args)
    -- Return the processed input number, or throw an error if invalid.
    -- Padding applies to the total number of digits, and does not count any
    -- group separators or decimal mark. For example, if a number would give
    -- "1,234,567.890" without padding, the result with padlen = 15 would
    -- have 5 extra zeros, giving "000,001,234,567.890".
    local parms = get_parms(args)
    local numstr = parms.numstr
    local padlen = parms.padlen
    if padlen then
        local reslen = #numstr
        if numstr:find('.', 1, true) then
            reslen = reslen - 1  -- do not count decimal mark
        end
        if padlen > reslen then
            if padlen > 100 then  -- silently limit to something reasonable
                padlen = 100
            end
            numstr = string.rep('0', padlen - reslen) .. numstr
        end
    end
    return parms.sign .. with_separator(parms, numstr)
end

local function number(frame)
    local success, result = pcall(do_number, frame.args)
    if success then
        return result
    end
    return '<strong class="error">' .. result .. '</strong>'
end

return { number = number }