"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.newRollbacks = exports.necessaryMigrations = exports.getMessagesQuery = exports.rollbacks = exports.migrations = exports.currentSchemaVersion = void 0;
const node_sqlite_1 = require("node:sqlite");
const log_1 = __importDefault(require("../../log"));
const path_1 = __importDefault(require("path"));
const fs_1 = require("fs");
const config_1 = __importDefault(require("../../config"));
const msg_1 = __importDefault(require("../../models/msg"));
const helper_1 = __importDefault(require("../../helper"));
exports.currentSchemaVersion = 1780272000000; // use `new Date().getTime()`
// Desired schema, adapt to the newest version and add migrations to the array below
const schema = [
    "CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
    "CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
    `CREATE TABLE migrations (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		version INTEGER NOT NULL UNIQUE,
		rollback_forbidden INTEGER DEFAULT 0 NOT NULL
	)`,
    `CREATE TABLE rollback_steps (
		id INTEGER PRIMARY KEY AUTOINCREMENT,
		migration_id INTEGER NOT NULL REFERENCES migrations ON DELETE CASCADE,
		step INTEGER NOT NULL,
		statement TEXT NOT NULL
	)`,
    "CREATE INDEX time ON messages (time)",
    "CREATE INDEX msg_type_idx on messages (type)", // needed for efficient storageCleaner queries
    // needed for efficient getMessages queries
    "CREATE INDEX network_channel_time ON messages (network, channel, time)",
];
// the migrations will be executed in an exclusive transaction as a whole
// add new migrations to the end, with the version being the new 'currentSchemaVersion'
// write a corresponding down migration into rollbacks
exports.migrations = [
    {
        version: 1672236339873,
        stmts: [
            "CREATE TABLE messages_new (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
            "INSERT INTO messages_new(network, channel, time, type, msg) select network, channel, time, type, msg from messages order by time asc",
            "DROP TABLE messages",
            "ALTER TABLE messages_new RENAME TO messages",
            "CREATE INDEX network_channel ON messages (network, channel)",
            "CREATE INDEX time ON messages (time)",
        ],
    },
    {
        version: 1679743888000,
        stmts: [
            `CREATE TABLE IF NOT EXISTS migrations (
				id INTEGER PRIMARY KEY AUTOINCREMENT,
				version INTEGER NOT NULL UNIQUE,
				rollback_forbidden INTEGER DEFAULT 0 NOT NULL
			)`,
            `CREATE TABLE IF NOT EXISTS rollback_steps (
				id INTEGER PRIMARY KEY AUTOINCREMENT,
				migration_id INTEGER NOT NULL REFERENCES migrations ON DELETE CASCADE,
				step INTEGER NOT NULL,
				statement TEXT NOT NULL
			)`,
        ],
    },
    {
        version: 1703322560448,
        stmts: ["CREATE INDEX msg_type_idx on messages (type)"],
    },
    {
        // replaces network_channel and also covers the sort in getMessages
        version: 1780272000000,
        stmts: [
            "CREATE INDEX IF NOT EXISTS network_channel_time ON messages (network, channel, time)",
            "DROP INDEX IF EXISTS network_channel",
        ],
    },
];
// down migrations need to restore the state of the prior version.
// rollback can be disallowed by adding rollback_forbidden: true to it
exports.rollbacks = [
    {
        version: 1672236339873,
        stmts: [], // changes aren't visible, left empty on purpose
    },
    {
        version: 1679743888000,
        stmts: [], // here we can't drop the tables, as we use them in the code, so just leave those in
    },
    {
        version: 1703322560448,
        stmts: ["drop INDEX msg_type_idx"],
    },
    {
        version: 1780272000000,
        stmts: [
            "DROP INDEX IF EXISTS network_channel_time",
            "CREATE INDEX IF NOT EXISTS network_channel ON messages (network, channel)",
        ],
    },
];
// exported for tests
exports.getMessagesQuery = "SELECT msg, type, time FROM messages WHERE network = ? AND channel = ? ORDER BY time DESC, id DESC LIMIT ?";
class SqliteMessageStorage {
    isEnabled;
    database;
    userName;
    constructor(userName) {
        this.userName = userName;
        this.isEnabled = false;
    }
    _enable(connection_string) {
        this.database = new node_sqlite_1.DatabaseSync(connection_string);
        try {
            this.run_migrations();
        }
        catch (e) {
            this.isEnabled = false;
            throw helper_1.default.catch_to_error("Migration failed", e);
        }
        this.isEnabled = true;
    }
    enable() {
        const logsPath = config_1.default.getUserLogsPath();
        const sqlitePath = path_1.default.join(logsPath, `${this.userName}.sqlite3`);
        (0, fs_1.mkdirSync)(logsPath, { recursive: true });
        this._enable(sqlitePath);
    }
    setup_new_db() {
        for (const stmt of schema) {
            this.database.exec(stmt);
        }
        this.database
            .prepare("INSERT INTO options (name, value) VALUES ('schema_version', ?)")
            .run(exports.currentSchemaVersion.toString());
    }
    current_version() {
        const have_options = this.database
            .prepare("select 1 from sqlite_master where type = 'table' and name = 'options'")
            .get();
        if (!have_options) {
            return 0;
        }
        const version = this.database
            .prepare("SELECT value FROM options WHERE name = 'schema_version'")
            .get();
        if (version === undefined) {
            // technically shouldn't happen, means something created a schema but didn't populate it
            // we'll try our best to recover
            return 0;
        }
        const storedSchemaVersion = parseInt(version.value, 10);
        return storedSchemaVersion;
    }
    update_version_in_db() {
        this.database
            .prepare("UPDATE options SET value = ? WHERE name = 'schema_version'")
            .run(exports.currentSchemaVersion.toString());
    }
    _run_migrations(dbVersion) {
        log_1.default.info(`sqlite messages schema version is out of date (${dbVersion} < ${exports.currentSchemaVersion}). Running migrations, this may take a while.`);
        const to_execute = necessaryMigrations(dbVersion);
        for (const stmt of to_execute.map((m) => m.stmts).flat()) {
            this.database.exec(stmt);
        }
        this.update_version_in_db();
    }
    run_migrations() {
        const version = this.current_version();
        if (version > exports.currentSchemaVersion) {
            throw `sqlite messages schema version is higher than expected (${version} > ${exports.currentSchemaVersion}). Is The Lounge out of date?`;
        }
        else if (version === exports.currentSchemaVersion) {
            return; // nothing to do
        }
        this.database.exec("BEGIN EXCLUSIVE TRANSACTION");
        try {
            if (version === 0) {
                this.setup_new_db();
            }
            else {
                this._run_migrations(version);
            }
            this.insert_rollback_since(version);
        }
        catch (err) {
            this.database.exec("ROLLBACK");
            throw err;
        }
        this.database.exec("COMMIT");
        this.database.exec("VACUUM");
    }
    // helper method that vacuums the db, meant to be used by migration related cli commands
    vacuum() {
        this.database.exec("VACUUM");
    }
    close() {
        if (!this.isEnabled) {
            return;
        }
        this.isEnabled = false;
        this.database.close();
    }
    fetch_rollbacks(since_version) {
        const res = this.database
            .prepare(`select version, rollback_forbidden, statement
				from rollback_steps
				join migrations on migrations.id=rollback_steps.migration_id
				where version > ?
				order by version desc, step asc`)
            .all(since_version);
        const result = [];
        // convert to Rollback[]
        // requires ordering in the sql statement
        for (const raw of res) {
            const last = result.at(-1);
            if (!last || raw.version !== last.version) {
                result.push({
                    version: raw.version,
                    rollback_forbidden: Boolean(raw.rollback_forbidden),
                    stmts: [raw.statement],
                });
            }
            else {
                last.stmts.push(raw.statement);
            }
        }
        return result;
    }
    delete_migrations_older_than(version) {
        this.database.prepare("delete from migrations where migrations.version > ?").run(version);
    }
    // returns whether any rollback statements were executed
    _downgrade_to(version) {
        const _rollbacks = this.fetch_rollbacks(version);
        if (_rollbacks.length === 0) {
            return false;
        }
        const forbidden = _rollbacks.find((item) => item.rollback_forbidden);
        if (forbidden) {
            throw Error(`can't downgrade past ${forbidden.version}`);
        }
        for (const rollback of _rollbacks) {
            for (const stmt of rollback.stmts) {
                this.database.exec(stmt);
            }
        }
        this.delete_migrations_older_than(version);
        this.update_version_in_db();
        return true;
    }
    downgrade_to(version) {
        if (version <= 0) {
            throw Error(`${version} is not a valid version to downgrade to`);
        }
        this.database.exec("BEGIN EXCLUSIVE TRANSACTION");
        let rolled_back = false;
        try {
            rolled_back = this._downgrade_to(version);
        }
        catch (err) {
            this.database.exec("ROLLBACK");
            throw err;
        }
        this.database.exec("COMMIT");
        if (rolled_back) {
            this.vacuum();
        }
        return version;
    }
    downgrade() {
        return this.downgrade_to(exports.currentSchemaVersion);
    }
    insert_rollback_since(version) {
        const missing = newRollbacks(version);
        for (const rollback of missing) {
            const migration = this.database
                .prepare(`insert into migrations
					(version, rollback_forbidden)
					values (?, ?)
					returning id`)
                .get(rollback.version, rollback.rollback_forbidden ? 1 : 0);
            let step = 0;
            for (const stmt of rollback.stmts) {
                this.database
                    .prepare(`insert into rollback_steps
						(migration_id, step, statement)
						values (?, ?, ?)`)
                    .run(migration.id, step, stmt);
                step++;
            }
        }
    }
    index(network, channel, msg) {
        if (!this.isEnabled) {
            return;
        }
        const clonedMsg = Object.keys(msg).reduce((newMsg, prop) => {
            // id is regenerated when messages are retrieved
            // previews are not stored because storage is cleared on lounge restart
            // type and time are stored in a separate column
            if (prop !== "id" && prop !== "previews" && prop !== "type" && prop !== "time") {
                newMsg[prop] = msg[prop];
            }
            return newMsg;
        }, {});
        this.database
            .prepare("INSERT INTO messages(network, channel, time, type, msg) VALUES(?, ?, ?, ?, ?)")
            .run(network.uuid, channel.name.toLowerCase(), msg.time.getTime(), msg.type, JSON.stringify(clonedMsg));
    }
    deleteChannel(network, channel) {
        if (!this.isEnabled) {
            return;
        }
        this.database
            .prepare("DELETE FROM messages WHERE network = ? AND channel = ?")
            .run(network.uuid, channel.name.toLowerCase());
    }
    getMessages(network, channel, nextID) {
        if (!this.isEnabled || config_1.default.values.maxHistory === 0) {
            return [];
        }
        // If unlimited history is specified, load 100k messages
        const limit = config_1.default.values.maxHistory < 0 ? 100000 : config_1.default.values.maxHistory;
        const rows = this.database
            .prepare(exports.getMessagesQuery)
            .all(network.uuid, channel.name.toLowerCase(), limit);
        return rows.reverse().map((row) => {
            const msg = JSON.parse(row.msg);
            msg.time = row.time;
            msg.type = row.type;
            const newMsg = new msg_1.default(msg);
            newMsg.id = nextID();
            return newMsg;
        });
    }
    search(query) {
        if (!this.isEnabled) {
            // this should never be hit as messageProvider is checked in client.search()
            throw new Error("search called but sqlite provider not enabled. This is a programming error");
        }
        // Using the '@' character to escape '%' and '_' in patterns.
        const escapedSearchTerm = query.searchTerm.replace(/([%_@])/g, "@$1");
        let select = "SELECT msg, type, time, network, channel FROM messages WHERE type = 'message' AND json_extract(msg, '$.text') LIKE ? ESCAPE '@'";
        const params = [`%${escapedSearchTerm}%`];
        if (query.networkUuid) {
            select += " AND network = ? ";
            params.push(query.networkUuid);
        }
        if (query.channelName) {
            select += " AND channel = ? ";
            params.push(query.channelName.toLowerCase());
        }
        const maxResults = 100;
        select += " ORDER BY time DESC, id DESC LIMIT ? OFFSET ? ";
        params.push(maxResults);
        params.push(query.offset);
        const rows = this.database.prepare(select).all(...params);
        return {
            ...query,
            results: parseSearchRowsToMessages(query.offset, rows).reverse(),
        };
    }
    deleteMessages(req) {
        let sql = "delete from messages where id in (select id from messages where\n";
        // We roughly get a timestamp from N days before.
        // We don't adjust for daylight savings time or other weird time jumps
        const millisecondsInDay = 24 * 60 * 60 * 1000;
        const deleteBefore = Date.now() - req.olderThanDays * millisecondsInDay;
        sql += "time <= ?\n";
        const params = [deleteBefore];
        if (req.messageTypes !== null) {
            const placeholder = new Array(req.messageTypes.length).fill("?").join(",");
            sql += `and type in (${placeholder})\n`;
            params.push(...req.messageTypes);
        }
        sql += "order by time asc\n";
        sql += "limit ?\n";
        params.push(req.limit);
        sql += ")";
        return this.database.prepare(sql).run(...params).changes;
    }
    canProvideMessages() {
        return this.isEnabled;
    }
}
// TODO: type any
function parseSearchRowsToMessages(id, rows) {
    const messages = [];
    for (const row of rows) {
        const msg = JSON.parse(row.msg);
        msg.time = row.time;
        msg.type = row.type;
        msg.networkUuid = row.network;
        msg.channelName = row.channel;
        msg.id = id;
        messages.push(new msg_1.default(msg));
        id += 1;
    }
    return messages;
}
function necessaryMigrations(since) {
    return exports.migrations.filter((m) => m.version > since);
}
exports.necessaryMigrations = necessaryMigrations;
function newRollbacks(since) {
    return exports.rollbacks.filter((r) => r.version > since);
}
exports.newRollbacks = newRollbacks;
exports.default = SqliteMessageStorage;
