---A cross-platform terminal ui library 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-2020, TBOOX Open Source Group.
--
-- @author      ruki
-- @file        program.lua
--

-- load modules
local log     = require("ltui/base/log")
local rect    = require("ltui/rect")
local point   = require("ltui/point")
local panel   = require("ltui/panel")
local event   = require("ltui/event")
local curses  = require("ltui/curses")
local action  = require("ltui/action")

-- define module
local program = program or panel()

-- init program
function program:init(name, argv)

    -- init main window
    local main_window = self:main_window()

    -- disable echo
    curses.echo(false)

    -- disable input cache
    curses.cbreak(true)

    -- disable newline
    curses.nl(false)

    -- init mouse support
    if curses.has_mouse() then
        -- curses.ALL_MOUSE_EVENTS may be set to mask unused events
        curses.mousemask(curses.ALL_MOUSE_EVENTS)
    end

    -- init colors
    if (curses.has_colors()) then
        curses.start_color()
    end

    -- disable main window cursor
    main_window:leaveok(false)

    -- enable special key map
    main_window:keypad(true)

    -- non-block for getch()
    main_window:nodelay(true)

    -- get 8-bits character for getch()
    main_window:meta(true)

    -- save the current arguments
    self._ARGV = argv

    -- init panel
    panel.init(self, name, rect {0, 0, curses.columns(), curses.lines()})

    -- init state
    self:state_set("focused", true)
    self:state_set("selected", true)
end

-- exit program
function program:exit()

    -- exit panel
    panel.exit(self)

    -- (attempt to) make sure the screen will be cleared
    -- if not restored by the curses driver
    self:main_window():clear()
    self:main_window():noutrefresh()
    curses.doupdate()

    -- exit curses
    assert(not curses.isdone())
    curses.done()
end

-- get the main window
function program:main_window()

    -- init main window if not exists
    local main_window = self._MAIN_WINDOW
    if not main_window then

        -- init main window
        main_window = curses.init()
        assert(main_window, "cannot init main window!")

        -- save main window
        self._MAIN_WINDOW = main_window
    end
    return main_window
end

-- get the command arguments
function program:argv()
    return self._ARGV
end

-- get the current event
function program:event()

    -- get event from the event queue first
    local event_queue = self._EVENT_QUEUE
    if event_queue then
        local e = event_queue[1]
        if e then
            table.remove(event_queue, 1)
            return e
        end
    end

    -- get input key
    local key_code, key_name, key_meta = self:_input_key()
    if key_code then
        if curses.KEY_MOUSE and key_code == curses.KEY_MOUSE then
            local code, x, y = curses.getmouse()
            local name = self:_mouse_map()[code]
            return event.mouse{code, x, y, name}
        end
        return event.keyboard{key_code, key_name, key_meta}
    end
end

-- on event
function program:on_event(e)

    -- get the top focused view
    local focused_view = self
    while focused_view:type() == "panel" and focused_view:current() do
        focused_view = focused_view:current()
    end

    -- do event for focused views
    while focused_view and focused_view ~= self do
        local parent = focused_view:parent()
        if focused_view:on_event(e) then
            return true
        end
        focused_view = parent
    end

    -- do event
    if e.type == event.ev_keyboard then
        -- resize?
        if e.key_name == "Resize" then
            self:bounds_set(rect {0, 0, curses.columns(), curses.lines()})
            return true
        -- refresh?
        elseif e.key_name == "Refresh" then
            self:invalidate()
            return true
        -- ctrl+c? quit program
        elseif e.key_name == "CtrlC" then
            self:send("cm_exit")
            return true
        end
    -- quit program?
    elseif event.is_command(e, "cm_exit") then
        self:quit()
        return true
    -- mouse events
    elseif e.type == event.ev_mouse and curses.has_mouse() and self:option("mouseable") then
        if e.btn_name == "BUTTON1_CLICKED" or e.btn_name == "BUTTON1_DOUBLE_CLICKED" then
            self:action_on(action.ac_on_clicked, e.x, e.y)
        end
    end
end

-- put an event to view
function program:put_event(e)

    -- init event queue
    self._EVENT_QUEUE = self._EVENT_QUEUE or {}

    -- put event to queue
    table.insert(self._EVENT_QUEUE, e)
end

-- send command
function program:send(command, extra)
    self:put_event(event.command {command, extra})
end

-- quit program
function program:quit()
    self:send("cm_quit")
end

-- run program loop
function program:loop(argv)

    -- do message loop
    local e = nil
    local sleep = true
    while true do

        -- get the current event
        e = self:event()

        -- do event
        if e then
            event.dump(e)
            self:on_event(e)
            sleep = false
        else
            -- do idle event
            self:on_event(event.idle())
            sleep = true
        end

        -- quit?
        if e and event.is_command(e, "cm_quit") then
            break
        end

        -- resize views
        if self:state("resize") then
            self:on_resize()
        end

        -- draw views
        self:on_draw()

        -- refresh views
        if self:state("refresh") then
            self:on_refresh()
        end

        -- wait some time, 50ms
        if sleep then
            curses.napms(50)
        end
    end
end

-- refresh program
function program:on_refresh()

    -- refresh views
    panel.on_refresh(self)

    -- trace
    log:print("%s: refresh ..", self)

    -- get main window
    local main_window = curses.main_window()

    -- refresh main window
    self:window():copy(main_window, 0, 0, 0, 0, self:height() - 1, self:width() - 1)

    -- refresh cursor
    self:_refresh_cursor()

    -- mark as refresh
    main_window:noutrefresh()

    -- do update
    curses.doupdate()
end

-- get key map
function program:_key_map()
    if not self._KEYMAP then
        self._KEYMAP =
        {
            [ 1] = "CtrlA", [ 2] = "CtrlB", [ 3] = "CtrlC",
            [ 4] = "CtrlD", [ 5] = "CtrlE", [ 6] = "CtrlF",
            [ 7] = "CtrlG", [ 8] = "CtrlH", [ 9] = "CtrlI",
            [10] = "CtrlJ", [11] = "CtrlK", [12] = "CtrlL",
            [13] = "CtrlM", [14] = "CtrlN", [15] = "CtrlO",
            [16] = "CtrlP", [17] = "CtrlQ", [18] = "CtrlR",
            [19] = "CtrlS", [20] = "CtrlT", [21] = "CtrlU",
            [22] = "CtrlV", [23] = "CtrlW", [24] = "CtrlX",
            [25] = "CtrlY", [26] = "CtrlZ",

            [  8] = "Backspace",
            [  9] = "Tab",
            [ 10] = "Enter",
            [ 13] = "Enter",
            [ 27] = "Esc",
            [ 31] = "CtrlBackspace",
            [127] = "Backspace",

            [curses.KEY_DOWN        ] = "Down",
            [curses.KEY_UP          ] = "Up",
            [curses.KEY_LEFT        ] = "Left",
            [curses.KEY_RIGHT       ] = "Right",
            [curses.KEY_HOME        ] = "Home",
            [curses.KEY_END         ] = "End",
            [curses.KEY_NPAGE       ] = "PageDown",
            [curses.KEY_PPAGE       ] = "PageUp",
            [curses.KEY_IC          ] = "Insert",
            [curses.KEY_DC          ] = "Delete",
            [curses.KEY_BACKSPACE   ] = "Backspace",
            [curses.KEY_F1          ] = "F1",
            [curses.KEY_F2          ] = "F2",
            [curses.KEY_F3          ] = "F3",
            [curses.KEY_F4          ] = "F4",
            [curses.KEY_F5          ] = "F5",
            [curses.KEY_F6          ] = "F6",
            [curses.KEY_F7          ] = "F7",
            [curses.KEY_F8          ] = "F8",
            [curses.KEY_F9          ] = "F9",
            [curses.KEY_F10         ] = "F10",
            [curses.KEY_F11         ] = "F11",
            [curses.KEY_F12         ] = "F12",

            [curses.KEY_RESIZE      ] = "Resize",
            [curses.KEY_REFRESH     ] = "Refresh",

            [curses.KEY_BTAB        ] = "ShiftTab",
            [curses.KEY_SDC         ] = "ShiftDelete",
            [curses.KEY_SIC         ] = "ShiftInsert",
            [curses.KEY_SEND        ] = "ShiftEnd",
            [curses.KEY_SHOME       ] = "ShiftHome",
            [curses.KEY_SLEFT       ] = "ShiftLeft",
            [curses.KEY_SRIGHT      ] = "ShiftRight",
            
            -- register virtual keys
            --
            -- @see https://github.com/xmake-io/xmake/issues/1610
            -- https://github.com/wmcbrine/PDCurses/blob/HEAD/curses.h#L766-L774
            [curses.KEY_C2 or -1    ] = "Down",
            [curses.KEY_A2 or -1    ] = "Up",
            [curses.KEY_B1 or -1    ] = "Left",
            [curses.KEY_B3 or -1    ] = "Right"
        }
    end
    return self._KEYMAP
end

-- get mouse map
function program:_mouse_map()
    if not self._MOUSEMAP then
        -- must be defined dynamically since it depends
        -- on curses implementation
        self._MOUSEMAP = {}
        for n, v in pairs(curses) do
            if (n:match('MOUSE') and n ~= 'KEY_MOUSE') or n:match('BUTTON') then
                self._MOUSEMAP[v] = n
            end
        end
    end
    return self._MOUSEMAP
end
-- get input key
function program:_input_key()

    -- get main window
    local main_window = self:main_window()

    -- get input character
    local ch = main_window:getch()
    if not ch then
        return
    end

    -- this is the time limit in ms within Esc-key sequences are detected as
    -- Alt-letter sequences. useful when we can't generate Alt-letter sequences
    -- directly. sometimes this pause may be longer than expected since the
    -- curses driver may also pause waiting for another key (ncurses-5.3)
    local esc_delay = 400

    -- get key map
    local key_map = self:_key_map()

    -- is alt?
    local alt = ch == 27
    if alt then

        -- get the next input character
        ch = main_window:getch()
        if not ch then

            -- since there is no way to know the time with millisecond precision
            -- we pause the the program until we get a key or the time limit
            -- is reached
            local t = 0
            while true do
                ch = main_window:getch()
                if ch or t >= esc_delay then
                    break
                end

                -- wait some time, 50ms
                curses.napms(50)
                t = t + 50
            end

            -- nothing was typed... return Esc
            if not ch then
                return 27, "Esc", false
            end
        end
        if ch > 96 and ch < 123 then
            ch = ch - 32
        end
    end

    -- map character to key
    local key = key_map[ch]
    local key_name = nil
    if key then
        key_name = alt and "Alt".. key or key
    elseif (ch < 256) then
        key_name = alt and "Alt".. string.char(ch) or string.char(ch)
    else
        return ch, '(noname)', alt
    end

    -- return key info

    return ch, key_name, alt
end

-- refresh cursor
function program:_refresh_cursor()

    -- get the top focused view
    local focused_view = self
    while focused_view:type() == "panel" and focused_view:current() do
        focused_view = focused_view:current()
    end

    -- get the cursor state of the top focused view
    local cursor_state = 0
    if focused_view and focused_view:state("cursor_visible") then
        cursor_state = focused_view:state("block_cursor") and 2 or 1
    end

    -- get the cursor position
    local cursor = focused_view and focused_view:cursor()() or point{0, 0}
    if cursor_state ~= 0 then
        local v = focused_view
        while v:parent() do

            -- update the cursor position
            cursor:addxy(v:bounds().sx, v:bounds().sy)

            -- is cursor visible?
            if cursor.x < 0 or cursor.y < 0 or cursor.x >= v:parent():width() or cursor.y >= v:parent():height() then
                cursor_state = 0
                break
            end

            -- get the parent view
            v = v:parent()
        end
    end

    -- update the cursor state
    curses.cursor_set(cursor_state)

    -- get main window
    local main_window = curses.main_window()

    -- trace
    log:print("cursor(%s): %s, %d", focused_view, cursor, cursor_state)

    -- move cursor position
    if cursor_state ~= 0 then
        main_window:move(cursor.y, cursor.x)
    else
        main_window:move(self:height() - 1, self:width() - 1)
    end
end

-- return module
return program
