/*
 * Bax xon
 * Copyright(c) 2000-2026 Lorne Taylor
 * MIT Licensed
 */

// TextDB
// light file system based data repository

import fs from "node:fs/promises";
import path from "node:path";
import { createReadStream } from "node:fs";
import readline from "node:readline";
import log from "./logger.js";

export default class TextDB {

    #data = new Map();
    #fs = {
        existing: new Set(),
        write: new Set(),
        rename: new Map(),
        remove: new Map(),
        errors: new Map()
    };
    #rootPath = ".";
    #ext = ".txt";
    #dirs = [];

    defaultOp = {
        rootPath: ".",
        ext: ".txt",
        dirs: [],
        fileFields: [],
        fileFieldsJoin: "-",
        fileSpaces: "",
    };

    constructor(op={}){

        this.op = { ...this.defaultOp, ...op };
        this.setRootPath(this.op.rootPath);
        this.setExt(this.op.ext);
        this.setDirs(this.op.dirs);
    }

	setRootPath(p){ 

		this.#rootPath = path.resolve(p);
	}

	getRootPath(){

		return this.#rootPath;
	}

    setExt(ext){

		if(typeof ext === "string") this.#ext = ext;
	}

    setDirs(dirs){

        if(Array.isArray(dirs)) this.#dirs = dirs;
        else if (typeof dirs === "string") this.#dirs = [dirs];
        else this.#dirs = [];
    }

	fsPending(){

		return this.#fs.write.size || this.#fs.rename.size || this.#fs.remove.size;
    }

    clear(){

        this.#data.clear();
        this.#fs.existing.clear();
        this.#fs.write.clear();
        this.#fs.rename.clear();
        this.#fs.remove.clear();
        this.#fs.errors.clear();
    }

    getFsState(){

        return {
            existing: [...this.#fs.existing],
            write: [...this.#fs.write],
            rename: Object.fromEntries(this.#fs.rename),
            remove: Object.fromEntries(this.#fs.remove),
            errors: Object.fromEntries(this.#fs.errors)
        };
    }

    // ------------------------------------------
    // DATA
    // ------------------------------------------

	async setData(){

        this.#data.clear();
        this.#fs.existing.clear();
        this.#fs.errors.clear();

        let dirs = this.#dirs;
        if(dirs.length < 1){
            log.warn(`No dirs set - finding all directories in root path`);
            try{
                const entries = await fs.readdir(this.#rootPath, { withFileTypes: true });
                dirs = entries.filter(d => d.isDirectory()).map(d => d.name);
            }
			catch(err){
                log.error("Failed to read root directory", err);
                return;
            }
        }

        for(const dir of dirs){

            const dirPath = path.join(this.#rootPath, dir);
            let entries = [];
            try{
                entries = await fs.readdir(dirPath, { withFileTypes: true });
            }
			catch(err){
                if(err.code !== "ENOENT") throw err;
                continue;
            }

            const files = entries.filter(f => f.isFile() && path.extname(f.name) === this.#ext);

            for(const file of files){
                const filePath = path.join(dirPath, file.name);
                let fileObj;
                try{ 
					fileObj = await this.parseFile(filePath);
				}
                catch(err){
                    this.#fs.errors.set(filePath, { error: "parse failed", err });
                    continue;
                }

                const fileName = path.basename(file.name, this.#ext);
                const canonicalId = `${dir}/${fileName}`;

                const cleaned = this.#cleanRecord({ ...fileObj });
                const newId = this.buildId(dir, cleaned);

                if(!newId || newId !== canonicalId){
                    this.#fs.errors.set(canonicalId, {
                        filePath,
                        canonicalId,
                        record: fileObj,
                        error: "buildId failed"
                    });
                    continue;
                }

                this.#data.set(newId, { ...cleaned, id: newId });
                this.#fs.existing.add(newId);
            }
        }

        if(this.#fs.errors.size > 0){
            log.warn(`There are [ ${this.#fs.errors.size} ] file errors`, this.getErrors());
		}
    }

    getData(){

        return Object.fromEntries([...this.#data.entries()].map(([k, v]) => [k, { ...v }]));
    }

    setDataFromObject(obj){

        if(typeof obj !== 'object' || obj === null) return;
        
        const incomingData = new Map();
        for(let [clientId, record] of Object.entries(obj)){
            let dir = clientId.includes("/") ? clientId.split("/")[0] : (this.op.dirs[0] || "test");
            
            // Rebuild the record metadata and calculate its real ID
            const {id: _discardId, ...meta} = record;
            const realId = this.buildId(dir, meta);
            
            if(realId){
                const cleaned = this.#cleanRecord({ ...meta, id: realId });
                incomingData.set(realId, cleaned);
            }
			else{
                log.warn("setDataFromObject: Could not build ID for record", meta);
            }
        }

        // 1. Handle removals: if it's in #data but not in incomingData, it must be deleted from FS
        for(const id of this.#data.keys()){
            if(!incomingData.has(id)){
                this.removeRecord(id);
            }
        }

        // 2. Handle additions/updates
        for(const [id, record] of incomingData){
            this.#data.set(id, record);
            this.#fs.write.add(id);
        }
    }

    getJSON(){

		return JSON.stringify(this.getData());
	}

	getErrors(){

		return Object.fromEntries(this.#fs.errors)
	}

    // ------------------------------------------
    // RECORD IDS
    // ------------------------------------------

	buildId(dir,record){

        const parts = [];

        for(const k of this.op.fileFields){
            if(!(k in record)){
                log.warn(`buildId FAIL - meta fileField [${k}] not in record`);
                return undefined;
            }
            if(record[k] === ""){
                log.warn(`buildId FAIL - meta fileField [${k}] cannot be empty`);
                return undefined;
            }
            try{
                parts.push(this.safePart(record[k]));
            }
			catch{
                log.warn(`buildId FAIL - meta field [${k}] contains invalid characters`);
                return undefined;
            }
        }

        const fileName = parts.join(this.op.fileFieldsJoin);
        if(fileName === "") return undefined;

        return `${dir}/${fileName}`;
    }

    safePart(v){

        let raw = String(v).trim();
        if(raw === "" || /[^a-zA-Z0-9\-_ ]/.test(raw)){
			throw new Error("safePart FAIL");
		}

        return raw.replace(/\s+/g, this.op.fileSpaces);
    }

    buildFilePath(id){

        const normalized = path.normalize(id + this.#ext);
        const filePath = path.join(this.#rootPath, normalized);
        if(!filePath.startsWith(this.#rootPath)){
            log.error("Path traversal detected in TextDB ID:", id);
            throw new Error("Access denied");
        }

        return filePath;
    }

    // ------------------------------------------
    // RECORDS
    // ------------------------------------------
	
    addRecord(dir, meta, body = ""){

        if(!dir){
			log.warn('addRecord FAIL - must pass dir');
			return {success:false};
		}

        // Set default timestamps
        const now = new Date().toISOString();
        const recordData = { 
            ...meta, 
            dateCreated: meta.dateCreated || now, 
            dateModified: now 
        };

        const newId = this.buildId(dir, recordData);
        if(!newId){ 
			log.warn('addRecord FAIL - invalid metadata');

			return {success:false}
		}
        if(this.#data.has(newId)){
			log.warn('addRecord FAIL - record already exists');

			return {success:false}
		}

        this.#data.set(newId, this.#cleanRecord({ ...recordData, _body: body, id: newId }));
        this.#fs.write.add(newId);

        return {success:true, newId};
    }

    updateRecord(existingId, meta, body){

        if(!this.#data.has(existingId)){
			log.warn("updateRecord FAIL - no matching record");

			return {success:false};
		}

        const dir = path.dirname(existingId);
        if(!dir){
			log.warn("updateRecord FAIL - id must have a dir");

			return {success:false};
		}

        const existingRecord = this.#data.get(existingId);
        
        // Update modified timestamp
        const now = new Date().toISOString();
        const merged = { 
            ...existingRecord, 
            ...meta, 
            dateModified: now 
        };

        const newId = this.buildId(dir, merged);

        if(!newId){
			log.warn("updateRecord FAIL");

			return {success:false};
		}

        if(newId !== existingId && this.#data.has(newId)){
			log.warn("updateRecord FAIL");

			return {success:false};
		}

        const record = {
            ...merged,
            _body: body === undefined ? existingRecord._body : body,
            id: newId
        };

        if(existingRecord.id === record.id){
            this.#data.set(record.id, this.#cleanRecord(record));
            this.#fs.write.add(record.id);
        }
		else{
            this.#renameRecord(existingId, record);
        }

        return {success:true, newId};
    }

    removeRecord(existingId){

        if(!this.#data.has(existingId)){
			log.warn("removeRecord FAIL - no matching record");
			return {success:false};
		}

        this.#data.delete(existingId);
        this.#fs.write.delete(existingId);
        this.#fs.rename.delete(existingId);

        if(this.#fs.existing.has(existingId)){
            const src = this.buildFilePath(existingId);
            this.#fs.remove.set(existingId, src);
        }

        return {success:true};
    }

    #renameRecord(existingId, record){

        if(!this.#data.has(existingId)){
			log.warn("renameRecord FAIL");
			return {success:false};
		}

        if(this.#data.has(record.id)){
			log.warn("renameRecord FAIL");
			return {success:false};
		}

        this.#data.set(record.id, this.#cleanRecord(record));
        this.#fs.write.add(record.id);

        this.#data.delete(existingId);
        this.#fs.write.delete(existingId);
        this.#fs.remove.delete(existingId);

        if(this.#fs.existing.has(existingId)){
            const src = this.buildFilePath(existingId);
            const dst = this.buildFilePath(record.id);
            const dstId = record.id;
            this.#fs.rename.set(existingId, { src, dst, dstId });
        }

        return {success:true};
    }

    #cleanRecord(record){

        for (let key in record) {
			// what about true / false?
            if(typeof record[key] !== 'string' && typeof record[key] !== 'number'){
                log.warn("record value not string/number", record.id, key);
                record[key] = "";
            }
			else{
                record[key] = String(record[key]);
            }
        }

        return record;
    }

    // ------------------------------------------
    // STREAM PARSER
    // ------------------------------------------
	
    async parseFile(filePath){

        const result = { _body: "" };
        const rl = readline.createInterface({
            input: createReadStream(filePath),
            crlfDelay: Infinity
        });

        let inHeader = true;
        for await (const line of rl) {
            if(inHeader && line === ""){
				inHeader = false;
				continue;
			}

            if(inHeader){
                const i = line.indexOf(":");
                if(i <= 0){
					log.warn(`Invalid header line in ${filePath}: ${line}`);
					continue;
				}
                const k = line.slice(0, i).trim();
                const v = line.slice(i + 1).trim();
                result[k] = v;
            }
			else{
                if(result._body) result._body += "\n";
                result._body += line;
            }
        }

        return result;
    }

    stringifyRecord(record){

        const exclude = new Set(["_body", "id"]);
        const meta = Object.entries(record)
            .filter(([k]) => !exclude.has(k))
            .map(([k, v]) => `${k}: ${String(v).trim()}`);

        return [...meta, "", record._body ?? ""].join("\n");
    }

    // ------------------------------------------
    // FILE LOCKING / ATOMIC WRITE
    // ------------------------------------------
	
    async acquireLock(file, retries = 5){

        const lock = file + ".lock";
        for(let i = 0; i < retries; i++){
            try{
                const fh = await fs.open(lock, "wx");
                await fh.writeFile(JSON.stringify({ pid: process.pid, time: Date.now() }));
                await fh.close();
                return lock;
            }
			catch(err){
                try{
                    const raw = await fs.readFile(lock, "utf8");
                    const data = JSON.parse(raw);
                    try { process.kill(data.pid, 0); } catch { await fs.unlink(lock); continue;}
                }
				catch{ await fs.unlink(lock); continue;}
                await new Promise(r => setTimeout(r, 50 + Math.random() * 100));
            }
        }
        throw new Error("Could not acquire lock");
    }

    async releaseLock(lock){ 

		try{
			await fs.unlink(lock);
		}
		catch{}
	}

    async atomicWrite(file, text){

        const tmp = file + ".tmp";
        const fh = await fs.open(tmp, "w");
        try{
			await fh.writeFile(text);
			await fh.sync();
		}
		finally{
			await fh.close();
		}
        await fs.rename(tmp, file);
    }

    async write(){

        await this.renameFiles();
        await this.removeFiles();

        for(const record of this.#data.values()){
            if(this.#fs.write.has(record.id)){
                const filePath = this.buildFilePath(record.id);
                const fileContent = this.stringifyRecord(record);
                await fs.mkdir(path.dirname(filePath), { recursive: true });
                try{
					await this.writeFile(filePath, fileContent);
					this.#fs.existing.add(record.id);
					this.#fs.write.delete(record.id);
				}
                catch(err){
					log.error("write failed", err);
				}
            }
        }
    }

    async renameFiles(){

        for(const [id, rename] of [...this.#fs.rename]){
            try{
                try{ await fs.unlink(rename.dst);}catch{}
                await fs.rename(rename.src, rename.dst);
                this.#fs.existing.delete(id);
                this.#fs.rename.delete(id);
                this.#fs.existing.add(rename.dstId);
                this.#fs.remove.delete(rename.dstId);
            }
			catch(err){
				log.error("rename failed", err);
			}
        }
    }

    async removeFiles(){

        for(const [id, remove] of [...this.#fs.remove]){
            try{
				await fs.unlink(remove); this.#fs.remove.delete(id);
			}
			catch(err){
				log.error("delete failed", err);
			}
        }
    }

    async writeFile(filePath, fileContent){

        const lock = await this.acquireLock(filePath);
        try{
			await this.atomicWrite(filePath, fileContent);
		}
        finally{
			await this.releaseLock(lock);
		}
    }
}
