login

local lud = require "ludweb"

local auth = require "auth"

--[[
common data representations:
    time INTEGER - unix timestamp
    color INTEGER - 24-bits RGB; MSB is red, LSB is blue
    priority INTEGER - no bounds; highest number <-> highest priority
]]
local schema = [[
CREATE TABLE IF NOT EXISTS User (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    nick TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    salt TEXT,
    hash TEXT,
    invited_by INTEGER REFERENCES User(id)
);
CREATE TABLE IF NOT EXISTS Invite (
    uuid TEXT PRIMARY KEY,
    expire INTEGER NOT NULL,
    user_id INTEGER REFERENCES User(id)
);
CREATE TABLE IF NOT EXISTS Project (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    time INTEGER NOT NULL,
    name TEXT NOT NULL UNIQUE,
    desc TEXT,
    goal TEXT,
    color TEXT,
    priority INTEGER NOT NULL,
    is_active INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Membership (
    proj_id INTEGER REFERENCES Project(id),
    user_id INTEGER REFERENCES User(id),
    PRIMARY KEY(proj_id, user_id)
);
CREATE TABLE IF NOT EXISTS Ticket (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    proj_id INTEGER NOT NULL REFERENCES Project(id),
    user_id INTEGER NOT NULL REFERENCES User(id),
    state_id INTEGER NOT NULL,
    time INTEGER NOT NULL,
    code INTEGER NOT NULL,
    title TEXT NOT NULL,
    desc TEXT,
    priority INTEGER NOT NULL,
    is_active INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Block (
    blocked INTEGER REFERENCES Ticket(id),
    blocker INTEGER REFERENCES Ticket(id),
    PRIMARY KEY(blocked, blocker)
);
CREATE TABLE IF NOT EXISTS Shift (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ticket_id INTEGER NOT NULL REFERENCES Ticket(id),
    old_stt_id INTEGER NOT NULL,
    new_stt_id INTEGER NOT NULL,
    user_id INTEGER NOT NULL REFERENCES User(id),
    time INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS Comment (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ticket_id INTEGER NOT NULL REFERENCES Ticket(id),
    user_id INTEGER NOT NULL REFERENCES User(id),
    time INTEGER NOT NULL,
    code INTEGER NOT NULL,
    text TEXT NOT NULL
);
]]

local Model = {}
Model.__index = Model

function Model:create_tables()
    self.db:execute_many(schema)
end

function Model:create_user(nick, name, password, uuid)
    self:expire_invites()
    local inv = self.db:execute("SELECT * FROM Invite WHERE uuid = ?;", uuid)[1]
    if inv == nil then return false end
    local salt = auth.get_salt()
    local hash = auth.hash_pass(password, salt)
    salt = auth.b64_enc(salt)
    hash = auth.b64_enc(hash)
    local query = "INSERT INTO User(nick, name, salt, hash, invited_by) VALUES (?, ?, ?, ?, ?);"
    self.db:execute(query, nick, name, salt, hash, inv.user_id)
    self.db:execute("DELETE FROM Invite WHERE uuid = ?;", uuid)
    return true
end

function Model:get_user_count()
    return self.db:execute("SELECT COUNT(*) as count FROM User;")[1].count
end

function Model:get_user(nick, full)
    local user = self.db:execute("SELECT * FROM User WHERE nick = ?;", nick)[1]
    if full and user ~= nil and user.invited_by ~= nil then
        local inviter = self.db:execute(
            "SELECT nick, name FROM User WHERE id = ?;", user.invited_by
        )[1]
        user.inviter_nick = inviter.nick
        user.inviter_name = inviter.name
    end
    return user
end

function Model:get_indexed_users()
    local query = "SELECT id, nick, name FROM User;"
    local users = {}
    for _, user in ipairs(self.db:execute(query)) do
        users[user.id] = {nick=user.nick, name=user.name}
    end
    return users
end

function Model:can_manage(user, other)
    local parent_id = other.invited_by
    while parent_id ~= nil do
        if parent_id == user.id then
            return true
        end
        other = self.db:execute("SELECT invited_by FROM User WHERE id = ?;", parent_id)[1]
        parent_id = other.invited_by
    end
    return false
end

function Model:del_user(user_id)
    self.db:execute("DELETE FROM Membership WHERE user_id = ?;", user_id)
    self.db:execute("DELETE FROM User WHERE id = ?;", user_id)
end

function Model:create_invite(user_id)
    local uuid = auth.hex(auth.uuid4())
    local expire = "strftime('%s', 'now', '+2 days')"
    local query = "INSERT INTO Invite(uuid, expire, user_id) VALUES (?, "..expire..", ?);"
    self.db:execute(query, uuid, user_id)
    return uuid
end

function Model:get_invites(user_id)
    return self.db:execute("SELECT * FROM Invite WHERE user_id = ?;", user_id)
end

function Model:expire_invites()
    self.db:execute("DELETE FROM Invite WHERE expire < strftime('%s');")
end

function Model:del_invite(user_id, uuid)
    self.db:execute("DELETE FROM Invite WHERE user_id = ? AND uuid = ?;", user_id, uuid)
end

function Model:create_project(user_id, name, desc, goal, color, priority)
    local query = [[
        INSERT INTO Project(time, name, desc, goal, color, priority, is_active)
        VALUES (strftime('%s'), ?, ?, ?, ?, ?, 1);
    ]]
    self.db:execute(query, name, desc, goal, color, priority)
    local proj_id = self.db:execute("SELECT id FROM Project WHERE name = ?;", name)[1].id
    self.db:execute("INSERT INTO Membership(proj_id, user_id) VALUES (?, ?);", proj_id, user_id)
    return proj_id
end

function Model:get_project(name)
    return self.db:execute("SELECT * FROM Project WHERE name = ?;", name)[1]
end

function Model:get_user_project(user_id, proj_name)
    return self.db:execute([[
        SELECT * FROM Project JOIN Membership ON Project.id = Membership.proj_id
        WHERE Membership.user_id = ? AND Project.name = ? ORDER BY priority DESC;
    ]], user_id, proj_name)[1]
end

function Model:get_user_projects(user_id)
    return self.db:execute([[
        SELECT * FROM Project JOIN Membership ON Project.id = Membership.proj_id
        WHERE Membership.user_id = ? ORDER BY is_active DESC, priority DESC;
    ]], user_id)
end

function Model:update_project(old_name, new_name, desc, goal, color, priority)
    local query = [[
        UPDATE Project SET name = ?, desc = ?, goal = ?, color = ?, priority = ?
        WHERE name = ?;
    ]]
    self.db:execute(query, new_name, desc, goal, color, priority, old_name)
end

function Model:archive_project(proj_id)
    self.db:execute("UPDATE Project SET is_active = FALSE WHERE id = ?;", proj_id)
end

function Model:restore_project(proj_id)
    self.db:execute("UPDATE Project SET is_active = TRUE WHERE id = ?;", proj_id)
end

function Model:del_project(proj_id)
    self.db:execute("DELETE FROM Membership WHERE proj_id = ?;", proj_id)
    self.db:execute("DELETE FROM Ticket WHERE proj_id = ?;", proj_id)
    -- TODO: delete associated comments and shifts
    self.db:execute("DELETE FROM Project WHERE id = ?;", proj_id)
end

-- generate new code for a child object
-- ticket_code = get_next_ticket_code("Ticket", "proj_id", proj_id)
-- comment_code = get_next_ticket_code("Comment", "ticket_id", ticket_id)
function Model:get_next_code(class, parent_row, parent_id)
    local query = "SELECT code FROM %s WHERE %s = ? ORDER BY code DESC LIMIT 1;"
    query = query:format(class, parent_row)
    local last_child = self.db:execute(query, parent_id)[1]
    local last_code
    if last_child == nil then
        last_code = 0
    else
        last_code = last_child.code
    end
    return last_code + 1
end

function Model:create_ticket(user_id, proj_id, title, desc, priority)
    local code = self:get_next_code("Ticket", "proj_id", proj_id)
    self.db:execute([[
        INSERT INTO Ticket(proj_id, user_id, state_id, time, code, title, desc, priority, is_active)
        VALUES (?, ?, 1, strftime('%s'), ?, ?, ?, ?, 1);
    ]], proj_id, user_id, code, title, desc, priority)
    return code
end

function Model:get_ticket(proj_id, code, full)
    local query
    if full then
        query = [[
            SELECT Ticket.*, User.nick AS author_nick, User.name AS author_name
            FROM Ticket JOIN User ON Ticket.user_id = User.id
            WHERE Ticket.proj_id = ? AND Ticket.code = ?;
        ]]
    else
        query = "SELECT * FROM Ticket WHERE proj_id = ? AND code = ?;"
    end
    return self.db:execute(query, proj_id, code)[1]
end

function Model:get_tickets(proj_id, state_id)
    local query = "SELECT * FROM Ticket WHERE proj_id = ? AND state_id = ?;"
    return self.db:execute(query, proj_id, state_id)
end

function Model:get_archived_tickets(proj_id)
    local query = [[
        SELECT * FROM Ticket WHERE proj_id = ? AND is_active = FALSE
        ORDER BY state_id ASC, code DESC;
    ]]
    local ticks = self.db:execute(query, proj_id)
    for i = 1, #ticks do
        ticks[i].state = self.states[ticks[i].state_id]
    end
    return ticks
end

function Model:update_ticket(tick_id, title, desc, priority)
    local query = "UPDATE Ticket SET title = ?, desc = ?, priority = ? WHERE id = ?;"
    self.db:execute(query, title, desc, priority, tick_id)
end

function Model:shift_ticket(user_id, ticket, state_id)
    local query = "SELECT * FROM %s WHERE ticket_id = ? ORDER BY time DESC LIMIT 1;"
    local last_comment = self.db:execute(query:format("Comment"), ticket.id)[1]
    local last_shift = self.db:execute(query:format("Shift"), ticket.id)[1]
    if last_comment ~= nil and last_shift ~= nil and last_shift.time < last_comment.time then
        -- don't undo last shift if a comment has been added after it
        last_shift = nil
    end
    if last_shift ~= nil and last_shift.old_stt_id == state_id then
        -- we're undoing the last shift: delete it from DB
        self.db:execute("DELETE FROM Shift WHERE id = ?;", last_shift.id)
    else
        -- we're doing a new shift: add it to DB
        self.db:execute([[
            INSERT INTO Shift(ticket_id, old_stt_id, new_stt_id, user_id, time)
            VALUES (?, ?, ?, ?, strftime('%s'));
        ]], ticket.id, ticket.state_id, state_id, user_id)
    end
    self.db:execute("UPDATE Ticket SET state_id = ? WHERE id = ?;", state_id, ticket.id)
end

function Model:archive_ticket(tick_id)
    self.db:execute("UPDATE Ticket SET is_active = FALSE WHERE id = ?;", tick_id)
end

function Model:restore_ticket(tick_id)
    self.db:execute("UPDATE Ticket SET is_active = TRUE WHERE id = ?;", tick_id)
end

function Model:del_ticket(tick_id)
    self.db:execute("DELETE FROM Ticket WHERE id = ?;", tick_id)
end

function Model:get_shifts(ticket_id, full)
    local query
    if full then
        query = [[
            SELECT Shift.*, User.nick AS author_nick, User.name AS author_name
            FROM Shift JOIN User ON Shift.user_id = User.id
            WHERE Shift.ticket_id = ? ORDER BY time ASC;
        ]]
    else
        query = "SELECT * FROM Shift WHERE ticket_id = ? ORDER BY time ASC;"
    end
    local shifts = self.db:execute(query, ticket_id)
    for i = 1, #shifts do
        shifts[i].old_stt_name = self.states[shifts[i].old_stt_id]
        shifts[i].new_stt_name = self.states[shifts[i].new_stt_id]
    end
    return shifts
end

function Model:add_comment(user_id, ticket_id, text)
    local code = self:get_next_code("Comment", "ticket_id", ticket_id)
    self.db:execute([[
        INSERT INTO Comment(ticket_id, user_id, time, code, text)
        VALUES (?, ?, strftime('%s'), ?, ?);
    ]], ticket_id, user_id, code, text)
    return code
end

function Model:get_comment(ticket_id, code)
    local query = "SELECT * FROM Comment WHERE ticket_id = ? AND code = ?;"
    return self.db:execute(query, ticket_id, code)[1]
end

function Model:get_comments(ticket_id, full)
    local query
    if full then
        query = [[
            SELECT Comment.*, User.nick AS author_nick, User.name AS author_name
            FROM Comment JOIN User ON Comment.user_id = User.id
            WHERE Comment.ticket_id = ? ORDER BY code ASC;
        ]]
    else
        query = "SELECT * FROM Comment WHERE ticket_id = ? ORDER BY code ASC;"
    end
    return self.db:execute(query, ticket_id)
end

function Model:update_comment(comm_id, text)
    self.db:execute("UPDATE Comment SET text = ? WHERE id = ?;", text, comm_id)
end

function Model:del_comment(comm_id)
    self.db:execute("DELETE FROM Comment WHERE id = ?;", comm_id)
end

-- return a list of events associated with a ticket sorted by time
-- currently there are two types of events: shifts and comments
function Model:get_events(ticket_id, full)
    local events = {}
    for _, ev in ipairs(self:get_shifts(ticket_id, full)) do
        ev.type = "shift"
        table.insert(events, ev)
    end
    for _, ev in ipairs(self:get_comments(ticket_id, full)) do
        ev.type = "comment"
        table.insert(events, ev)
    end
    table.sort(events, function (a, b) return a.time < b.time end)
    return events
end

-- return a list of columns ordered by state ID
-- each column is a list of cards ordered by priority
-- each card is a ticket DB object with some keys added:
--   author_nick, author_name, proj_name, proj_color, proj_priority
function Model:get_board(user_id, proj_id)
    local filter = ""
    if proj_id ~= nil then
        filter = " AND Ticket.proj_id = ?"
    end
    local query = [[
    SELECT
        Ticket.*,
        User.nick AS author_nick,
        User.name AS author_name,
        Project.name AS proj_name,
        Project.color AS proj_color,
        Project.priority AS proj_priority
    FROM Membership
    JOIN Project ON Membership.proj_id = Project.id
    JOIN Ticket ON Project.id = Ticket.proj_id
    JOIN User ON Ticket.user_id = User.id
    WHERE
        Membership.user_id = ?]]..filter..[[
        AND Project.is_active AND Ticket.is_active
    ORDER BY Ticket.priority DESC, proj_priority DESC, Ticket.code ASC;
    ]]
    local tickets
    if proj_id ~= nil then
        tickets = self.db:execute(query, user_id, proj_id)
    else
        tickets = self.db:execute(query, user_id)
    end
    local columns = {}
    for i = 1, #self.states do
        table.insert(columns, {})
    end
    for i, ticket in ipairs(tickets) do
        table.insert(columns[ticket.state_id], ticket)
    end
    return columns
end

function Model:close()
    self.db:close()
end

local function open(path)
    local self = setmetatable({path=path}, Model)
    self.db = lud.sqlite.open(path)
    self.states = {"backlog", "design", "progress", "review", "done"}
    return self
end

return {open=open}