--!A cross-platform build utility based on Lua
--
-- Licensed under the Apache License, Version 2.0 (the "License");
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
--     http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- Copyright (C) 2015-present, Xmake Open Source Community.
--
-- @author      ruki
-- @file        tty.lua
--

-- define module
local tty = tty or {}

-- load modules
local io = require("base/io")
local path = require("base/path")

-- save metatable and builtin functions
tty._term_mode = tty._term_mode or tty.term_mode
tty._session_id = tty._session_id or tty.session_id

-- @see https://www2.ccs.neu.edu/research/gpc/VonaUtils/vona/terminal/vtansi.htm
-- http://www.termsys.demon.co.uk/vtansi.htm

-- write control characters
function tty._iowrite(...)
    local isatty = tty._ISATTY
    if isatty == nil then
        isatty = io.isatty()
        tty._ISATTY = isatty
    end
    if isatty then
        io.write(...)
    end
end

-- get colorterm setting
--
-- COLORTERM: 8color/color8, 256color/color256, truecolor, nocolor
--
function tty._colorterm()
    local colorterm = tty._COLORTERM
    if colorterm == nil then
        colorterm = os.getenv("XMAKE_COLORTERM") or os.getenv("COLORTERM") or ""
        tty._COLORTERM = colorterm
    end
    return colorterm
end

-- erases from the current cursor position to the end of the current line.
function tty.erase_line_to_end()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[K")
    end
    return tty
end

-- erases from the current cursor position to the start of the current line.
function tty.erase_line_to_start()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[1K")
    end
    return tty
end

-- erases the entire current line
function tty.erase_line()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[2K")
    end
    return tty
end

-- erases the screen from the current line down to the bottom of the screen.
function tty.erase_down()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[J")
    end
    return tty
end

-- erases the screen from the current line up to the top of the screen.
function tty.erase_up()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[1J")
    end
    return tty
end

-- erases the screen with the background colour and moves the cursor to home.
function tty.erase_screen()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[2J")
    end
    return tty
end

-- save current cursor position.
function tty.cursor_save()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[s")
    end
    return tty
end

-- restores cursor position after a save cursor.
function tty.cursor_restore()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[u")
    end
    return tty
end

-- save current cursor position and color attrs
function tty.cursor_and_attrs_save()
    if tty.has_vtansi() then
        tty._iowrite("\x1b7")
    end
    return tty
end

-- restores cursor position and color attrs after a save cursor.
function tty.cursor_and_attrs_restore()
    if tty.has_vtansi() then
        tty._iowrite("\x1b8")
    end
    return tty
end

-- move cursor to absolute position (row, col)
-- row and col are 1-based (1, 1) is the top-left corner
function tty.cursor_move(row, col)
    if tty.has_vtansi() then
        row = row or 1
        col = col or 1
        if row > 0 and col > 0 then
            tty._iowrite(string.format("\x1b[%d;%dH", row, col))
        end
    end
    return tty
end

-- move cursor up by n lines
function tty.cursor_move_up(n)
    if tty.has_vtansi() then
        n = n or 1
        if n > 0 then
            tty._iowrite(string.format("\x1b[%dA", n))
        end
    end
    return tty
end

-- move cursor down by n lines
function tty.cursor_move_down(n)
    if tty.has_vtansi() then
        n = n or 1
        if n > 0 then
            tty._iowrite(string.format("\x1b[%dB", n))
        end
    end
    return tty
end

-- move cursor forward (right) by n columns
function tty.cursor_move_right(n)
    if tty.has_vtansi() then
        n = n or 1
        if n > 0 then
            tty._iowrite(string.format("\x1b[%dC", n))
        end
    end
    return tty
end

-- move cursor backward (left) by n columns
function tty.cursor_move_left(n)
    if tty.has_vtansi() then
        n = n or 1
        if n > 0 then
            tty._iowrite(string.format("\x1b[%dD", n))
        end
    end
    return tty
end

-- move cursor to specified column
function tty.cursor_move_to_col(col)
    if tty.has_vtansi() then
        col = col or 1
        if col > 0 then
            tty._iowrite(string.format("\x1b[%dG", col))
        end
    end
    return tty
end

-- hide cursor
function tty.cursor_hide()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[?25l")
    end
    return tty
end

-- show cursor
function tty.cursor_show()
    if tty.has_vtansi() then
        tty._iowrite("\x1b[?25h")
    end
    return tty
end

-- carriage return
function tty.cr()
    tty._iowrite("\r")
    return tty
end

-- flush control
function tty.flush()
    if io.isatty() then
        io.flush()
    end
    return tty
end

function tty._find_shell_from_parent_on_windows()
    local shell
    local winos = require("base/winos")
    if winos.processes then
        local processes = winos.processes()
        if processes then
            local pid = os.getpid()
            local processes_map = {}
            for _, process in ipairs(processes) do
                processes_map[process.pid] = process
            end
            local count = 0
            while pid and pid ~= 0 and count < 10 do
                count = count + 1
                local process = processes_map[pid]
                if not process then
                    break
                end
                local name = process.name
                if name then
                    name = name:lower()
                    if name:sub(-4) == ".exe" then
                        name = name:sub(1, #name - 4)
                    end
                    for _, shellname in ipairs({"zsh", "bash", "fish", "nu", "elvish", "pwsh", "powershell", "cmd", "sh"}) do
                        if name == shellname then
                            shell = shellname
                            break
                        end
                    end
                end
                if shell then
                    break
                end
                pid = process.parent_pid or process.ppid -- for backward compatibility
            end
        end
    end

    if not shell then
        local subhost = xmake._SUBHOST
        if subhost == "windows" then
            if os.getenv("PROMPT") then
                shell = "cmd"
            else
                local ok, result = os.iorun("pwsh -v")
                if ok then
                    shell = "pwsh"
                else
                    shell = "powershell"
                end
            end
        end
    end
    return shell
end


function tty._find_shell_from_parent_on_linux()
    local pid = os.getpid()
    local count = 0
    local shell
    while pid ~= 0 and count < 4 do
        count = count + 1
        local shell_name = nil
        local shell_path = nil
        if os.isfile("/proc/" .. pid .. "/exe") then
            local link = os.readlink("/proc/" .. pid .. "/exe")
            if link then
                shell_path = link
            end
        end

        if not shell_path and os.isfile("/proc/" .. pid .. "/comm") then
             shell_name = io.readfile("/proc/" .. pid .. "/comm")
             if shell_name then
                 shell_name = shell_name:trim()
             end
        end
        if shell_path then
            shell_name = path.filename(shell_path)
        end

        if shell_name then
            shell_name = shell_name:ltrim("-")
            for _, name in ipairs({"zsh", "bash", "fish", "nu", "elvish", "pwsh", "sh"}) do
                if shell_name == name then
                    shell = name
                    break
                end
            end
            if shell then
                break
            end
        end

        local stat = io.readfile("/proc/" .. pid .. "/stat")
        local ppid = stat and tonumber(stat:match(".*%) %S+ (%d+)"))
        if not ppid or ppid == 0 then
            break
        end
        pid = ppid
    end
    return shell
end

-- find the shell from the parent process
function tty._find_shell_from_parent()

    -- for windows
    if os.host() == "windows" then
        return tty._find_shell_from_parent_on_windows()
    end

    -- for linux
    if os.host() == "linux" and os.isfile("/proc/self/stat") then
        return tty._find_shell_from_parent_on_linux()
    end
end

-- get shell name, e.g. "bash", "zsh", "powershell", "cmd", "nu"
--
-- @return      the shell name string
--
function tty.shell()
    local shell = tty._SHELL
    if shell == nil then
        if os.getenv("NU_VERSION") then
            shell = "nu"
        end
        -- try to find the shell from the parent process
        if not shell then
            shell = tty._find_shell_from_parent()
        end

        if not shell then
            shell = os.getenv("XMAKE_SHELL")
        end
        if not shell then
            shell = os.getenv("SHELL")
            if shell then
                for _, shellname in ipairs({"zsh", "bash", "fish", "nu", "elvish", "pwsh", "sh"}) do
                    if shell:find(shellname) then
                        shell = shellname
                        break
                    end
                end
            end
        end
        tty._SHELL = shell or "sh"
    end
    return tty._SHELL
end

-- get terminal name
--  - xterm
--  - cmd
--  - vstudio (in visual studio)
--  - vscode (in vscode)
--  - msys2
--  - cygwin
--  - pwsh
--  - powershell
--  - mintty
--  - windows-terminal
--  - gnome-terminal
--  - xfce4-terminal
--  - konsole
--  - terminator
--  - rxvt
--  - lxterminal
--  - ghostty
--  - unknown
--
function tty.term()
    local term = tty._TERM
    if term == nil then

        -- get term from $TERM_PROGRAM
        if term == nil then
            local TERM_PROGRAM = os.getenv("TERM_PROGRAM")
            if TERM_PROGRAM ~= nil then
                if TERM_PROGRAM:find("vscode", 1, true) then
                    term = "vscode"
                elseif TERM_PROGRAM == "mintty" then
                    term = "mintty" -- git bash
                elseif TERM_PROGRAM == "ghostty" then
                    term = "ghostty"
                end
            end
        end

        -- get term from $TERM
        if term == nil then
            local TERM = os.getenv("TERM")
            if TERM ~= nil then
                if TERM:find("ghostty", 1, true) then
                    term = "ghostty"
                elseif TERM:find("xterm", 1, true) then
                    term = "xterm"
                elseif TERM == "cygwin" then
                    term = "cygwin"
                elseif TERM:find("alacritty", 1, true) then
                    term = "alacritty"
                end
            end
        end

        -- get term from system
        if term == nil then
            local subhost = xmake._SUBHOST
            if subhost == "windows" then
                if os.getenv("XMAKE_IN_VSTUDIO") then
                    term = "vstudio"
                elseif os.getenv("WT_SESSION") then
                    term = "windows-terminal"
                else
                    term = tty.shell()
                end
            elseif subhost == "msys" then
                term = "msys2"
            elseif subhost == "cygwin" then
                term = "cygwin"
            elseif subhost == "macosx" then
                term = "xterm"
            end
        end
        tty._TERM = term or "unknown"
    end
    return tty._TERM
end

-- does the terminal support emoji?
--
-- @return      true if supported
--
function tty.has_emoji()
    local has_emoji = tty._HAS_EMOJI
    if has_emoji == nil then
        local term = tty.term()
        local winos = require("base/winos")

        -- before win8? disable it
        if has_emoji == nil and (os.host() == "windows" and winos.version():le("win8")) then
            has_emoji = false
        end

        -- on msys2/cygwin/powershell? disable it
        if has_emoji == nil and (term == "msys2" or term == "cygwin" or term == "powershell") then
            has_emoji = false
        end

        -- enable it by default
        if has_emoji == nil then
            has_emoji = true
        end
        tty._HAS_EMOJI = has_emoji or false
    end
    return has_emoji
end

-- does the terminal support VT/ANSI escape codes?
--
-- @return      true if supported
--
function tty.has_vtansi()
    return tty.has_color8()
end

-- has 8 colors?
function tty.has_color8()
    local has_color8 = tty._HAS_COLOR8
    if has_color8 == nil then

        -- detect it from $COLORTERM
        if has_color8 == nil then
            local colorterm = tty._colorterm()
            if colorterm == "nocolor" then
                has_color8 = false
            elseif colorterm and colorterm:find("8color", 1, true) then
                has_color8 = true
            elseif tty.has_color256() or tty.has_color24() then
                has_color8 = true
            end
        end

        -- detect it from $TERM
        local term = tty.term()
        if has_color8 == nil then
            if term == "vstudio" then
                has_color8 = false
            elseif term == "xterm" or term == "mintty" then
                has_color8 = true
            end
        end

        -- detect it from system
        if has_color8 == nil then
            if os.host() == "windows" then
                local winos = require("base/winos")
                if os.getenv("ANSICON") then
                    has_color8 = true
                elseif winos.version():le("win8") then
                    has_color8 = false
                else
                    has_color8 = true
                end
            else
                -- alway enabled for unix-like system
                has_color8 = true
            end
        end
        tty._HAS_COLOR8 = has_color8 or false
    end
    return has_color8
end

-- has 256 colors?
function tty.has_color256()

    local has_color256 = tty._HAS_COLOR256
    if has_color256 == nil then

        -- detect it from $COLORTERM
        if has_color256 == nil then
            local colorterm = tty._colorterm()
            if colorterm == "nocolor" then
                has_color256 = false
            elseif colorterm and (colorterm:find("256color", 1, true) or colorterm:find("color256", 1, true)) then
                has_color256 = true
            elseif tty.has_color24() then
                has_color256 = true
            end
        end

        -- detect it from $TERM
        local term = tty.term()
        local term_env = os.getenv("TERM")
        if has_color256 == nil then
            if term == "vstudio" then
                has_color256 = false
            elseif term_env and (term_env:find("256color", 1, true) or term_env:find("color256", 1, true)) then
                has_color256 = true
            end
        end

        -- detect it from system
        if has_color256 == nil then
            if os.host() == "windows" then
                has_color256 = false
            elseif os.host() == "linux" or os.host("macosx") then
                -- alway enabled for linux/macOS, $TERM maybe xterm, not xterm-256color, but it is supported
                has_color256 = true
            else
                has_color256 = false
            end
        end
        tty._HAS_COLOR256 = has_color256 or false
    end
    return has_color256
end

-- has 24bits true color?
--
-- There's no reliable way, and ncurses/terminfo's maintainer expressed he has no intent on introducing support.
-- S-Lang author added a check for $COLORTERM containing either "truecolor" or "24bit" (case sensitive).
-- In turn, VTE, Konsole and iTerm2 set this variable to "truecolor" (it's been there in VTE for a while,
-- it's relatively new and maybe still git-only in Konsole and iTerm2).
--
-- This is obviously not a reliable method, and is not forwarded via sudo, ssh etc. However, whenever it errs,
-- it errs on the safe side: does not advertise support whereas it's actually supported.
-- App developers can freely choose to check for this same variable, or introduce their own method
-- (e.g. an option in their config file), whichever matches better the overall design of the given app.
-- Checking $COLORTERM is recommended though, since that would lead to a more unique desktop experience
-- where the user has to set one variable only and it takes effect across all the apps, rather than something
-- separately for each app.
--
function tty.has_color24()

    local has_color24 = tty._HAS_COLOR24
    if has_color24 == nil then

        -- detect it from $COLORTERM
        if has_color24 == nil then
            local colorterm = tty._colorterm()
            if colorterm == "nocolor" then
                has_color24 = false
            elseif colorterm and (colorterm:find("truecolor", 1, true) or colorterm:find("24bit", 1, true)) then
                has_color24 = true
            end
        end

        -- detect it from $TERM
        local term = tty.term()
        local term_env = os.getenv("TERM")
        if has_color256 == nil then
            if term == "vstudio" then
                has_color256 = false
            elseif term_env and (term_env:find("truecolor", 1, true) or term_env:find("24bit", 1, true)) then
                has_color256 = true
            end
        end
        tty._HAS_COLOR24 = has_color24 or false
    end
    return has_color24
end

-- get term mode, e.g. stdin, stdout, stderr
--
-- local oldmode = tty.term_mode(stdtype)
-- local oldmode = tty.term_mode(stdtype, newmode)
--
function tty.term_mode(stdtype, newmode)
    local oldmode = 0
    if tty._term_mode then
        if stdtype == "stdin" then
            oldmode = tty._term_mode(1, newmode)
        elseif stdtype == "stdout" then
            oldmode = tty._term_mode(2, newmode)
        elseif stdtype == "stderr" then
            oldmode = tty._term_mode(3, newmode)
        end
    end
    return oldmode
end

-- get the terminal session id
--
-- @return      the session id string
--
function tty.session_id()
    local session_id = tty._SESSION_ID
    if session_id == nil then
        if tty._session_id then
            local sid = tty._session_id()
            if sid then
                local hash = require("base/hash")
                session_id = hash.strhash32(sid)
            end
        end
        if not session_id then
            session_id = "00000000"
        end
        tty._SESSION_ID = session_id
    end
    return session_id
end

-- return module
return tty
