--!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        io.lua
--

-- define module
local io        = io or {}
local _file     = _file or {}
local _filelock = _filelock or {}

-- load modules
local path      = require("base/path")
local table     = require("base/table")
local string    = require("base/string")
local todisplay = require("base/todisplay")

-- save metatable and builtin functions
io._file        = _file
io._filelock    = _filelock
io._stdfile     = io._stdfile or io.stdfile

-- new a file
function _file.new(filepath, cdata, isstdfile)
    local file = table.inherit(_file)
    file._PATH = isstdfile and filepath or path.absolute(filepath)
    file._FILE = cdata
    setmetatable(file, _file)
    return file
end

-- get the file name
function _file:name()
    if not self._NAME then
        self._NAME = path.filename(self:path())
    end
    return self._NAME
end

-- get the file path
function _file:path()
    return self._PATH
end

-- close file
function _file:close()

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- close file
    ok, errors = io.file_close(self:cdata())
    if ok then
        self._FILE = nil
    end
    return ok, errors
end

-- tostring(file)
function _file:__tostring()
    local str = self:path()
    if #str > 16 then
        str = ".." .. str:sub(#str - 16, #str)
    end
    return "<file: " .. str .. ">"
end

-- todisplay(file)
function _file:__todisplay()
    local size = _file.size(self)
    local filepath = _file.path(self)
    if not size then
        return string.format("file${reset} %s", todisplay(filepath))
    end

    local unit = "B"
    if size >= 1000 then
        size = size / 1024
        unit = "KiB"
    end
    if size >= 1000 then
        size = size / 1024
        unit = "MiB"
    end
    if size >= 1000 then
        size = size / 1024
        unit = "GiB"
    end
    return string.format("file${reset}(${color.dump.number}%.3f%s${reset}) %s", size, unit, todisplay(filepath))
end

-- gc(file)
function _file:__gc()
    if self:cdata() and io.file_close(self:cdata()) then
        -- remove ref to notify gc that it should be freed
        self._FILE = nil
    end
end

-- get file length
function _file:__len()
    return _file.size(self)
end

-- get cdata
function _file:cdata()
    return self._FILE
end

-- get file rawfd
function _file:rawfd()

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return nil, errors
    end

    -- get file rawfd
    local result, errors = io.file_rawfd(self:cdata())
    if not result and errors then
        errors = string.format("%s: %s", self, errors)
    end
    return result, errors
end

-- get file size
function _file:size()

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return nil, errors
    end

    -- get file size
    local result, errors = io.file_size(self:cdata())
    if not result and errors then
        errors = string.format("%s: %s", self, errors)
    end
    return result, errors
end

-- read data from file
--
-- @param fmt       the reading format
-- @param opt       the options
--                  - continuation (concat string with the given continuation characters)
--
function _file:read(fmt, opt)

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return nil, errors
    end

    -- read file
    opt = opt or {}
    local result, errors = io.file_read(self:cdata(), fmt, opt.continuation)
    if errors then
        errors = string.format("%s: %s", self, errors)
    end
    return result, errors
end

-- is readable?
function _file:readable()

    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    return io.file_readable(self:cdata())
end

-- write data to file
function _file:write(...)

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- data items
    local items = table.pack(...)
    for idx, item in ipairs(items) do
        if type(item) == "table" and item.caddr and item.size then
            -- write bytes
            items[idx] = {data = item:caddr(), size = item:size()}
        end
    end

    -- write file
    ok, errors = io.file_write(self:cdata(), table.unpack(items))
    if not ok and errors then
        errors = string.format("%s: %s", self, errors)
    end
    return ok, errors
end

-- seek offset at file
function _file:seek(whence, offset)

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- seek file
    local result, errors = io.file_seek(self:cdata(), whence, offset)
    if not result and errors then
        errors = string.format("%s: %s", self, errors)
    end
    return result, errors
end

-- flush data to file
function _file:flush()

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- flush file
    ok, errors = io.file_flush(self:cdata())
    if not ok and errors then
        errors = string.format("%s: %s", self, errors)
    end
    return ok, errors
end

-- this file is a tty?
function _file:isatty()
    local isatty_cached = self._ISATTY
    if isatty_cached ~= nil then
        return isatty_cached
    end

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return nil, errors
    end

    -- is a tty?
    ok, errors = io.file_isatty(self:cdata())
    if ok == nil and errors then
        errors = string.format("%s: %s", self, errors)
    end
    self._ISATTY = ok
    return ok, errors
end

-- ensure the file is opened
function _file:_ensure_opened()
    if not self:cdata() then
        return false, string.format("%s: has been closed!", self)
    end
    return true
end

-- read all lines from file
function _file:lines(opt)
    opt = opt or {}
    return function()
        local l = _file.read(self, "l", opt)
        if not l and opt.close_on_finished then
            _file.close(self)
        end
        return l
    end
end

-- print file
function _file:print(...)
    return _file.write(self, string.format(...), "\n")
end

-- printf file
function _file:printf(...)
    return _file.write(self, string.format(...))
end

-- save object
function _file:save(object, opt)
    local str, errors = string.serialize(object, opt)
    if errors then
        return false, errors
    else
        return _file.write(self, str)
    end
end

-- load object
function _file:load()
    local data, err = _file.read(self, "*all")
    if err then
        return nil, err
    end
    if data and type(data) == "string" then
        return data:deserialize()
    end
end

-- new an filelock
function _filelock.new(lockpath, lock)
    local filelock = table.inherit(_filelock)
    filelock._PATH = path.absolute(lockpath)
    filelock._LOCK = lock
    filelock._LOCKED_NUM = 0
    setmetatable(filelock, _filelock)
    return filelock
end

-- get the filelock name
function _filelock:name()
    if not self._NAME then
        self._NAME = path.filename(self:path())
    end
    return self._NAME
end

-- get the filelock path
function _filelock:path()
    return self._PATH
end

-- get the cdata
function _filelock:cdata()
    return self._LOCK
end

-- is locked?
function _filelock:islocked()
    return self._LOCKED_NUM > 0
end

-- lock file
--
-- @param opt       the argument option, {shared = true}
--
-- @return          ok, errors
--
function _filelock:lock(opt)

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- lock it
    if self._LOCKED_NUM > 0 or io.filelock_lock(self:cdata(), opt) then
        self._LOCKED_NUM = self._LOCKED_NUM + 1
        return true
    else
        return false, string.format("%s: lock failed!", self)
    end
end

-- try to lock file
--
-- @param opt       the argument option, {shared = true}
--
-- @return          ok, errors
--
function _filelock:trylock(opt)

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- try lock it
    if self._LOCKED_NUM > 0 or io.filelock_trylock(self:cdata(), opt) then
        self._LOCKED_NUM = self._LOCKED_NUM + 1
        return true
    else
        return false, string.format("%s: trylock failed!", self)
    end
end

-- unlock file
function _filelock:unlock(opt)

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- unlock it
    if self._LOCKED_NUM > 1 or (self._LOCKED_NUM > 0 and io.filelock_unlock(self:cdata())) then
        if self._LOCKED_NUM > 0 then
            self._LOCKED_NUM = self._LOCKED_NUM - 1
        else
            self._LOCKED_NUM = 0
        end
        return true
    else
        return false, string.format("%s: unlock failed!", self)
    end
end

-- close filelock
function _filelock:close()

    -- ensure opened
    local ok, errors = self:_ensure_opened()
    if not ok then
        return false, errors
    end

    -- close it
    ok = io.filelock_close(self:cdata())
    if ok then
        self._LOCK = nil
        self._LOCKED_NUM = 0
    end
    return ok
end

-- ensure the file is opened
function _filelock:_ensure_opened()
    if not self:cdata() then
        return false, string.format("%s: has been closed!", self)
    end
    return true
end

-- tostring(filelock)
function _filelock:__tostring()
    local str = _filelock.path(self)
    if #str > 16 then
        str = ".." .. str:sub(#str - 16, #str)
    end
    return "<filelock: " .. str .. ">"
end

-- todisplay(filelock)
function _filelock:__todisplay()
    local str = _filelock.path(self)
    return "filelock${reset} " .. todisplay(str)
end

-- gc(filelock)
function _filelock:__gc()
    if self:cdata() and io.filelock_close(self:cdata()) then
        self._LOCK = nil
        self._LOCKED_NUM = 0
    end
end

-- read all lines from file
--
-- @param filepath  the file path
-- @param opt       the options, e.g. {encoding = "utf8", continuation = "\\"}
-- @return          the lines iterator
--
function io.lines(filepath, opt)
    opt = opt or {}
    if opt.close_on_finished == nil then
        opt.close_on_finished = true
    end
    local file = io.open(filepath, "r", opt)
    if not file then
        return function() return nil end
    end
    return file:lines(opt)
end

-- read all data from file
--
-- @param filepath  the file path
-- @param opt       the options, e.g. {encoding = "utf8"}
-- @return          the file content string
--
function io.readfile(filepath, opt)
    opt = opt or {}
    local file, errors = io.open(tostring(filepath), "r", opt)
    if not file then
        return nil, errors
    end
    local data, err = file:read("*all", opt)
    file:close()
    return data, err
end

function io.read(fmt, opt)
    return io.stdin:read(fmt, opt)
end

function io.readable()
    return io.stdin:readable()
end

function io.write(...)
    return io.stdout:write(...)
end

function io.print(...)
    return io.stdout:print(...)
end

function io.printf(...)
    return io.stdout:printf(...)
end

function io.flush()
    return io.stdout:flush()
end

-- write data to file
--
-- @param filepath  the file path
-- @param data      the data string
-- @param opt       the options, e.g. {encoding = "utf8"}
-- @return          true on success, or false and error info
--
function io.writefile(filepath, data, opt)
    opt = opt or {}
    local file, errors = io.open(tostring(filepath), "w", opt)
    if not file then
        return false, errors
    end
    file:write(data)
    file:close()
    return true
end

-- isatty
function io.isatty(file)
    file = file or io.stdout
    return file:isatty()
end

-- get std file, /dev/stdin, /dev/stdout, /dev/stderr
function io.stdfile(filepath)
    local file = nil
    if filepath == "/dev/stdin" then
        file = io._stdfile(1)
    elseif filepath == "/dev/stdout" then
        file = io._stdfile(2)
    elseif filepath == "/dev/stderr" then
        file = io._stdfile(3)
    end
    if file then
        return _file.new(filepath, file, true)
    else
        return nil, string.format("failed to get std file: %s", filepath)
    end
end

-- open file
--
-- @param filepath      the file path
-- @param mode          the open mode, e.g. 'r', 'rb', 'w+', 'a+', ..
-- @param opt           the options
--                      - encoding, e.g. utf8, utf16, utf16le, utf16be ..
--
function io.open(filepath, mode, opt)

    -- init option and mode
    opt  = opt or {}
    mode = mode or "r"

    -- open it
    filepath = tostring(filepath)
    local file = io.file_open(filepath, mode .. (opt.encoding or ""))
    if file then
        return _file.new(filepath, file)
    else
        return nil, string.format("cannot open file: %s, %s", filepath, os.strerror())
    end
end

-- open a filelock
--
-- @param filepath  the lock file path
-- @return          the filelock object
--
function io.openlock(filepath)
    filepath = tostring(filepath)
    local lock = io.filelock_open(filepath)
    if lock then
        return _filelock.new(filepath, lock)
    else
        return nil, string.format("cannot open lock: %s, %s", filepath, os.strerror())
    end
end

-- close file
function io.close(file)
    return (file or io.stdout):close()
end

-- convert file encoding
--
-- @param inputfile     the input file path
-- @param outputfile    the output file path
-- @param opt           the options
--                      - from: the input encoding, e.g. utf8, utf8bom, utf16, utf16le, utf16lebom, utf16be, gb2312, gbk, iso8859, ucs2, ucs4, utf32 .. (default: utf8)
--                      - to: the output encoding, e.g. utf8, utf8bom, utf16, utf16le, utf16lebom, utf16be, gb2312, gbk, iso8859, ucs2, ucs4, utf32 .. (default: utf8)
function io.convert(inputfile, outputfile, opt)
    opt = opt or {}
    inputfile = tostring(inputfile)
    outputfile = tostring(outputfile)
    local from = opt.from or "utf8"
    local to = opt.to or "utf8"

    -- try using c implementation first
    if io.file_convert and io.file_convert(inputfile, outputfile, from, to) then
        return true
    end

    -- fallback to lua implementation (slow but works)
    local content, errors = io.readfile(inputfile, {encoding = from})
    if not content then
        return false, errors
    end
    return io.writefile(outputfile, content, {encoding = to})
end

-- save object to the given filepath
--
-- @param filepath  the file path
-- @param object    the object to serialize (table, string, number, boolean)
-- @param opt       the options, e.g. {orderkeys = true}
-- @return          true on success, or false and error info
--
function io.save(filepath, object, opt)
    opt = opt or {}
    assert(filepath and object)
    filepath = tostring(filepath)

    -- we save it when file is only changed, we can ensure file modify time.
    local oldstr
    if opt.only_changed and os.isfile(filepath) then
        oldstr = io.readfile(filepath, {encoding = "binary"})
    end

    local ok, errors, str
    str, errors = string.serialize(object, opt)
    if str then
        local write = true
        if opt.only_changed then
            if oldstr == str then
                write = false
            end
        end
        if write then
            ok, errors = io.writefile(filepath, str, {encoding = "binary"})
        else
            ok = true
        end
    end
    if not ok then
        return false, string.format("save %s failed, %s!", filepath, errors)
    end
    return true
end

-- load object from the given file
--
-- @param filepath  the file path
-- @param opt       the options, e.g. {encoding = "utf8"}
-- @return          the deserialized object, or nil and error info
--
function io.load(filepath, opt)
    assert(filepath)

    opt = opt or {}
    filepath = tostring(filepath)
    local file, err = io.open(filepath, "rb", opt)
    if err then
        return nil, err
    end
    local result, errors = file:load()
    file:close()
    return result, errors
end

-- gsub the given file and return replaced data
--
-- @param filepath  the file path
-- @param pattern   the lua pattern string
-- @param replace   the replacement string or function
-- @param opt       the options, e.g. {encoding = "utf8"}
-- @return          the replaced data string, the replacement count
--
function io.gsub(filepath, pattern, replace, opt)

    -- read all data from file
    opt = opt or {}
    local data, errors = io.readfile(filepath, opt)
    if not data then return nil, 0, errors end

    -- replace it
    local count = 0
    if type(data) == "string" then
        data, count = data:gsub(pattern, replace)
    else
        return nil, 0, string.format("data is not string!")
    end

    -- replace ok?
    if count ~= 0 then
        -- write all data to file
        local ok, errors = io.writefile(filepath, data, opt)
        if not ok then return nil, 0, errors end
    end
    return data, count
end

-- replace text of the given file and return new file data
--
-- @param filepath  the file path
-- @param pattern   the plain text or lua pattern to search
-- @param replace   the replacement string
-- @param opt       the options, e.g. {plain = true, encoding = "utf8"}
-- @return          the replaced data string, the replacement count
--
function io.replace(filepath, pattern, replace, opt)
    opt = opt or {}
    local data, errors = io.readfile(filepath, opt)
    if not data then return nil, 0, errors end

    local count = 0
    if type(data) == "string" then
        data, count = data:replace(pattern, replace, opt)
    else
        return nil, 0, string.format("data is not string!")
    end
    if count ~= 0 then
        local ok, errors = io.writefile(filepath, data, opt)
        if not ok then return nil, 0, errors end
    end
    return data, count
end

-- insert text before line number in the given file and return new file data
function io.insert(filepath, lineidx, text, opt)
    opt = opt or {}
    local data, errors = io.readfile(filepath, opt)
    if not data then return nil, errors end

    local newdata
    if type(data) == "string" then
        newdata = {}
        for idx, line in ipairs(data:split("\n")) do
            if idx == lineidx then
                table.insert(newdata, text)
            end
            table.insert(newdata, line)
        end
    else
        return nil, string.format("data is not string!")
    end
    if newdata and #newdata > 0 then
        local rn = data:find("\r\n", 1, true)
        data = table.concat(newdata, rn and "\r\n" or "\n")
        local ok, errors = io.writefile(filepath, data, opt)
        if not ok then return nil, errors end
    end
    return data, count
end

-- cat the given file
--
-- @param filepath  the file path
-- @param linecount the line count to read (optional, read all if nil)
-- @param opt       the options, e.g. {encoding = "utf8"}
-- @return          the file content string
--
function io.cat(filepath, linecount, opt)
    opt = opt or {}
    local file = io.open(filepath, "r", opt)
    if file then
        local count = 1
        for line in file:lines(opt) do
            io.write(line, "\n")
            if linecount and count >= linecount then
                break
            end
            count = count + 1
        end
        file:close()
    end
end

-- tail the given file
function io.tail(filepath, linecount, opt)
    opt = opt or {}
    if linecount < 0 then
        return io.cat(filepath, opt)
    end

    -- open file
    local file = io.open(filepath, "r", opt)
    if file then
        local lines = {}
        for line in file:lines(opt) do
            table.insert(lines, line)
        end

        local tails = {}
        if #lines ~= 0 then
            local count = 1
            for index = #lines, 1, -1 do
                table.insert(tails, lines[index])
                if linecount and count >= linecount then
                    break
                end
                count = count + 1
            end
        end

        if #tails ~= 0 then
            for index = #tails, 1, -1 do
                io.print(tails[index])
            end
        end
        file:close()
    end
end

-- lazy loading stdfile
io.stdin  = nil
io.stdout = nil
io.stderr = nil
setmetatable(io, { __index = function (tbl, key)
        local val = rawget(tbl, key)
        if val == nil and (key == "stdin" or key == "stdout" or key == "stderr") then
            val = io.stdfile("/dev/" .. key)
            if val ~= nil then
                rawset(tbl, key, val)
            end
        end
        return val
    end})

-- return module
return io
