import express from "express";
import cors from "cors";
import bcrypt from "bcrypt";
import fileUpload from "express-fileupload";
import path from "path";
import childProcess from "child_process";
import levenshtein from "js-levenshtein";
import cookieParser from "cookie-parser";
import { fileURLToPath } from "url";
import sqlite3 from "sqlite3";
import fs from "fs/promises";

const PORT = 8000;

let sessions = [];
let videoQueue = [];

const db = new sqlite3.Database("database.sqlite3");

function dbGet(query, ...parameters) {
    return new Promise((resolve, reject) => db.get(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
}

function dbAll(query, ...parameters) {
    return new Promise((resolve, reject) => db.all(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
}

function dbRun(query, ...parameters) {
    return new Promise((resolve, reject) => db.run(query, parameters, (err, data) => err ? reject(err) : resolve(data)));
}

function runCommand(cmd, args) {
    return new Promise(resolve => {
        const process = childProcess.spawn(cmd, args);

        let data = "";
        let error = false;
        process.stdout.on("data", chunk => data += chunk);
        process.stderr.on("data", chunk => {
            error = true;
            data += chunk;
        });

        process.on("close", () => resolve({ error, data }));
    });
}

function videoPath(id) {
    return `videos/${id}.webm`;
}

function randomString(length) {
    const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789";
    let result = "";
    for (let i = 0; i < length; ++i) {
        result += chars[Math.floor(chars.length * Math.random())];
    }
    return result;
}

function dirname() {
    return path.dirname(fileURLToPath(import.meta.url));
}

function authorized() {
    return async (req, res, next) => {
        const token = (() => {
            if (req.cookies && "token" in req.cookies) {
                return req.cookies["token"];
            } else if ("token" in req.query) {
                return req.query["token"];
            } else if (req.method === "post" && "token" in req.body) {
                return req.body["token"];
            } else {
                return null;
            }
        })();
        if (token === null) {
            return res.status(400).json({ ok: false, error: "unauthorized" });
        }
        const session = sessions.find(session => session.token === token);
        if (session === undefined) {
            return res.status(400).json({ ok: false, error: "unauthorized" });
        }
        const user = await dbGet("SELECT * FROM users WHERE id = ?", session.userId);
        if (user === undefined) {
            throw new Error("error: session with invalid userId");
        }
        req.user = user;
        next();
    }
}

const app = express();
app.use(cors());
app.use(express.json());
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));

app.use("/", express.static("public/"));
app.use("/videos", express.static("videos/"));

app.post("/api/register", async (req, res) => {
    const { username, password } = req.body;

    if (typeof username !== "string" || typeof password !== "string") {
        return res.status(400).json({ ok: false, error: "bad request" });
    }

    const existingUser = await dbGet("SELECT * FROM users WHERE username = ?", username);

    if (existingUser !== undefined) {
        return res.status(400).json({ ok: false, error: "username taken" });
    }

    const passwordHash = await bcrypt.hash(password, 10);

    await dbGet("INSERT INTO users (username, password) VALUES (?, ?)", username, passwordHash);

    return res.status(200).json({ ok: true });
});

app.post("/api/login", async (req, res) => {
    const { username, password } = req.body;

    if (typeof username !== "string" || typeof password !== "string") {
        return res.status(400).json({ ok: false, error: "bad request" });
    }

    const user = await dbGet("SELECT * FROM users WHERE username = ?", username);

    if (user === undefined) {
        return res.status(400).json({ ok: false, error: "wrong username/password" });
    }

    if (!await bcrypt.compare(password, user.password)) {
        return res.status(400).json({ ok: false, error: "wrong username/password" });
    }

    sessions = sessions.filter(session => session.userId !== user.id);

    const token = randomString(64);
    const session = { userId: user.id, token };

    sessions.push(session);

    res.clearCookie("token");
    res.cookie("token", token);

    return res.status(200).json({ ok: true, session });
});


app.get("/api/search", async (req, res) => {
    const page = +req.query.page || 0;
    const search = req.query.query;

    if (!search) {
        return res.status(400).json({ ok: false, error: "bad request" });
    }

    const [start, end] = [20 * page, 20];
    const videos = await dbAll(`
        SELECT videos.*, users.username AS author
        FROM videos
        JOIN users ON users.id = videos.user_id
        WHERE title LIKE CONCAT('%', ?, '%')
        LIMIT ?
        OFFSET ?
    `, search, end, start);

    const { total } = await dbGet("SELECT COUNT(*) AS total FROM videos");

    return res.status(200).json({ ok: true, videos, total });
});

app.get("/api/logout", authorized(), (req, res) => {
    sessions = sessions.filter(session => session.userId !== req.user.id);
    return res.status(200).json({ ok: true });
});

app.post("/api/upload-video", authorized(), fileUpload({ limits: { fileSize: 2 ** 26 }, useTempFiles: true }), async (req, res) => {
    const { title, description } = req.body;
    if (!title) {
        return res.status(400).json({ ok: false, error: "bad request" });
    }

    if (!req.files || !req.files.video) {
        return res.status(400).json({ ok: false, error: "bad request" });
    }

    if (req.files.video.mimetype !== "video/mp4") {
        return res.status(415).json({ ok: false, error: "bad mimetype" });
    }

    const userId = req.user.id;
    const id = randomString(4);
    const tempPath = req.files.video.tempFilePath;
    const newPath = path.join(dirname(), videoPath(id));
    const thumbnailPath = path.join(dirname(), "videos", `${id}.png`);

    const durationResult = await runCommand("ffprobe", ["-i", tempPath, "-show_format", "-loglevel", "error"]);
    if (durationResult.error) {
        return res.status(400).json({ ok: false, error: "invalid video file" });
    }

    const duration = parseFloat(durationResult.data.match(/duration=([.\d]+)/)[1]) * 1_000_000;;

    const queueItem = {
        videoId: id,
        userId,
        title,
        errors: [],
        progress: 0,
        duration,
    };

    videoQueue.push(queueItem);

    const progressPath = path.join(dirname(), "tmp", "progress", `${id}.txt`)
    const uploadProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-b:v", "1M", "-b:a", "192k", "-progress", progressPath, "-loglevel", "error", newPath]);
    const thumbnailProcess = childProcess.spawn("ffmpeg", ["-i", tempPath, "-ss", "00:00:01.000", "-vframes", "1", "-loglevel", "error", thumbnailPath]);

    uploadProcess.stderr.on("data", data => queueItem.errors.push(data.toString()));
    thumbnailProcess.stderr.on("data", data => queueItem.errors.push(data.toString()));

    uploadProcess.on("close", () => {
        if (queueItem.errors.length) {
            return;
        }

        dbRun(
            "INSERT INTO videos (id, user_id, title, description, created_at) VALUES (?, ?, ?, ?, ?)",
            id, userId, title, description ?? "", new Date().toISOString()
        );

        const index = videoQueue.indexOf(item => item.videoId === queueItem.videoId)
        videoQueue.splice(index, 1);
    });

    return res.status(200).json({ ok: true });
});

app.get("/api/video-queue", authorized(), (req, res) => {
    const userId = req.user.id;

    const queue = videoQueue.filter(item => item.userId === userId);

    return res.status(200).json({ ok: true, queue });
});

app.get("/api/video-info", async (req, res) => {
    const id = req.query["id"];

    const video = await dbGet(`
        SELECT videos.id, videos.title, videos.description, videos.created_at, users.username AS author
        FROM videos
        JOIN users ON users.id = videos.user_id
        WHERE videos.id = ?
        LIMIT 1
    `, id);

    if (!video) {
        return res.status(404).json({ ok: false, error: "video not found" });
    }

    video.path = path.join("/", videoPath(id));

    return res.status(200).json({ ok: true, video });
});

app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).json({ ok: false, error: "server error" })
});

async function main() {
    (async () => {
        const progressPath = path.join(dirname(), "tmp", "progress");

        await fs.mkdir(progressPath, { recursive: true });

        const watch = fs.watch(progressPath, { recursive: true });

        for await (const event of watch) {
            const videoId = event.filename.replace(".txt", "");
            const queueItem = videoQueue.find(item => item.videoId === videoId);

            if (!queueItem) {
                continue;
            }

            fs.readFile(path.join(progressPath, event.filename), "utf-8")
                .then(string => {
                    const index = string.lastIndexOf("out_time_ms=");
                    if (index === -1) return;

                    const length = parseInt(string.slice(index).match(/out_time_ms=(\d+)/)[1]);

                    for (const i in videoQueue) {
                        if (videoQueue[i].videoId !== videoId) continue;

                        videoQueue[i].progress = length;
                    }
                });
        }
    })();

    app.listen(PORT, () => {
        console.log("app at http://localhost:8000/");
    });
}

main();