/*!
 * Baxxon
 * Copyright(c) 2000-2026 Lorne Taylor
 * MIT Licensed
 */

import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import querystring from 'node:querystring';
import { marked } from 'marked';
import log from './logger.js';
import Templates from './templates.js';
// node createServer requestListener
// instantiate Bx; handle POST and GET requests

export default function (req, res){

	log.start(req);

	const bx = new Bx(req, res);

	// handle http request methods
	if(req.method === 'POST'){
		bx.httpPOSTHandler();
	}
    else if(req.method === 'GET' || req.method === 'HEAD'){ 
		bx.httpGETHandler();
	}
}

// this class is passed to all module import scripts

export class Bx {
	
	constructor(req, res){

		this.setOptions();

		this.server = {
			req: req,
			res: res
		}

		if(req){
			this.host = this.server.req.headers.host;
			this.reqURL = new URL( 'http://' + req.headers.host + req.url );
		}

		this.setPaths();

		this.api = {
			isValid: false,
		}
		this.mod = {
			isValid: false,
		}
		this.params = [];
		this.templates = new Templates();
		this.log = log;
	}

// ------------------------------------
// Options
// ------------------------------------
	
	static defaultOp = {
		server: {
			host: process.env.BAXXON_HOST || '0.0.0.0',
			portHttp: process.env.BAXXON_PORT_HTTP || 3000,
			portHttps: process.env.BAXXON_PORT_HTTPS || 3001,
		},
		vhosts: {
			dir: 'sites',
			defaultDir: '_default',
			domainDirs: {},
		},
		errors: {
			outputType: 'html', // html | text | file
			outputFileDir: '',
		},
		staticFiles: {
			dir: 'public',
			defaultFilename: 'index.html',
			cache: 'no-cache',
		},
		jsModules: {
			dir: 'modules',
			ext: '.js',
			defaultFilename: 'index.js',
		},
		errorTemplate:`<!doctype html>
<html lang="en">
	<head>
		<meta charset="utf-8" />
		<meta content="width=device-width" name="viewport">
		<meta name="generator" content="Baxxon - HTML Error" />
		<title>{title}</title>
	</head>
	<body>
		<h1>{title}</h1>
		<pre>{msg}</pre>
	</body>
</html>`
	}
	op = {}

	setOptions(){

		// Start with a clone of defaults to avoid mutating the prototype
		this.op = JSON.parse(JSON.stringify(Bx.defaultOp));
		
		// Load from package.json if it exists
		const pkgPath = path.join(process.cwd(), 'package.json');
		if(fs.existsSync(pkgPath)){
			try{
				const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
				if(pkg.baxxon){
					// Merge user config categories into defaults
					for(const key of Object.keys(pkg.baxxon)){
						if(this.op.hasOwnProperty(key)){
							// Use spread for shallow-merge within each category
							this.op[key] = {...this.op[key], ...pkg.baxxon[key]}
						}
						else{
							this.op[key] = pkg.baxxon[key];
						}
					}
				}
			}
			catch(err){
				console.error('Failed to parse package.json for config', err);
			}
		}
	}


// ------------------------------------
// Version
// ------------------------------------

	version(){

		try{
			// Path to the framework's own package.json (relative to app.js)
			const pkgPath = path.join(import.meta.dirname, '../package.json');
			const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
			return pkg.version;
		}
		catch(err){
			log.error('Failed to read framework version', err);
			return false;
		}
	}


// ------------------------------------
// Paths
// ------------------------------------
	
	paths = "";

	setPaths(){

		this.paths = {
			doc: path.join(import.meta.dirname, '../'),
			app: path.resolve("./"),
			site: "",
			stat: "",
			mod: ""
		}
		this.setSiteRoot(),
		this.setStaticRoot(),
		this.setModuleRoot()
	}

	setSiteRoot(){

		const dir = this.op.vhosts.dir;
		const defaultDir = this.op.vhosts.defaultDir;
		const domains = Object.entries(this.op.vhosts.domainDirs);
	
		// validate vhost domains and override default site if found
		if(this.host){
			log.note(`attempting to validate vhosts for host [${this.host}]...`)
			for(const [domain, domainDir] of domains){
				//log.note(`checking domain [${domain}]...`);
				var regx = new RegExp(domain.replaceAll('.', "\\."));
				if(regx.test(this.host)){
					// valid when vhost found in host
					this.paths.site = path.join(this.paths.app, dir, domainDir);
					log.note(`vhost match for domain directory [${dir}/${domain}]`);
					return;
				} 
			}
		}

		// default dir 
		this.paths.site = path.join(this.paths.app, dir, defaultDir);
		if(this.host){
			log.note(`no vhosts matched for [${this.host}] - using default site directory [${dir}/${defaultDir}]`); 
		}
	}

	setStaticRoot(){

		const dir = this.op.staticFiles.dir;
		this.paths.stat = path.join(this.paths.site, dir);
	}

	setModuleRoot(){

		const dir = this.op.jsModules.dir;
		this.paths.mod = path.join(this.paths.site, dir);
	}

// ------------------------------------
// Content types
// ------------------------------------

	validContentTypes = {
		'.ico': 'image/x-icon',
		'.png': 'image/png',
		'.jpeg': 'image/jpeg',
		'.jpg': 'image/jpeg',
		'.svg': 'image/svg+xml',
		'.gif': 'image/gif',
		'.wav': 'audio/wave',
		'.pdf': 'application/pdf',
		'.json': 'application/json',
		'.text': 'text/plain',
		'.txt': 'text/plain',
		'.html': 'text/html',
		'.xml': 'text/xml',
		'.css': 'text/css',
		'.js': 'text/javascript',
		'.md': 'text/markdown', 
	}

	getContentType(ext){

		if(this.validContentTypes.hasOwnProperty(ext)){

			return this.validContentTypes[ext];
		}
	}
	

// ------------------------------------
// HTTP Request Handlers
// ------------------------------------
	
	httpPOSTHandler(){

		log.note( 'Processing POST request...' );

		// parse request body stream
		let body = "";

		this.server.req.on("data", chunk => body += chunk);
		this.server.req.on("end", ()=>{

			const parsed = function(body, type){
				switch(type){
					case 'application/json':
						return JSON.parse( body );
					case 'application/x-www-form-urlencoded':
						return querystring.parse(body);
					default:
						return body; // raw text
				}
			}
			const type = this.server.req.headers["content-type"];
			const reqURI = this.reqURL.pathname;
			
			this.server.req.body = parsed(body, type);

			log.note('POST request URI', reqURI);
			log.note('POST request URL', this.reqURL);
			log.note('POST request headers', this.server.req.headers);
			log.note('POST request body', this.server.req.body);

			this.moduleReqParser(reqURI);
			if(this.mod.isValid){
				this.moduleImport();

				return;
			}

			this.render.error(404,"File not found");

			return;
		});
	}

	httpGETHandler(){

		log.note('processing GET request...');
		log.note(`Request URL [${this.reqURL}]`);

		const reqPath = this.reqURL.pathname;
		const reqExt = path.extname(reqPath);

		// block hidden files
		if(reqPath.split('/').some(part => part.startsWith('.'))){
			log.warn('Block request for hidden file', reqPath);
			this.render.error(403, 'Access denied');

			return;
		}

		log.note('parsing request for file extension...');

		// URL has extension : static file
		if(reqExt.length > 0){
			log.note( `Request has extension [${reqExt}]`, `trying static files...`);
			this.render.FILE(reqPath);

			return;
		}

		// modules
		const modFilename = this.getModuleDefaultFilename();
		const modExt = this.op.jsModules.ext;
	
		log.note('Unresolved request trying modules...');
		this.mod = this.reqParser(reqPath, this.paths.mod, modExt);
		if(this.mod.isValid){
			this.moduleImport();

			return;
		}

		// module directory
		log.note('Unresolved request trying module directory...');

		this.mod = this.reqParser(`${reqPath}/${modFilename}`, this.paths.mod, modExt);
		if(this.mod.isValid){
			this.moduleImport();

			return;
		}
		
		// default module
		log.note('Unresolved request trying default module...', modFilename);
		this.mod = this.reqParser(modFilename, this.paths.mod, modExt);
		if(this.mod.isValid){
			this.moduleImport();

			return;
		}

		// default static file
		const staticFilename = this.getStaticDefaultFilename();
		log.note('Unresolved request trying default static...', staticFilename);
		this.render.FILE(staticFilename);

		return;
	}

	reqParser(reqPath, testPath, ext){

		const result = {
			isValid: false,
			path: testPath,
			pathSegments: '',
			searchSegments: '',
		}

		// parse request URL into path segments
		var pathSegments = [];
		if(reqPath.startsWith('/')){
			reqPath = reqPath.slice(1);
		}
		if(reqPath.endsWith('/')){
			reqPath = reqPath.slice(0, -1);
		}
		if(reqPath.includes('/')){
			pathSegments = reqPath.split('/').filter(segment => segment.length > 0);
		}
		else if(reqPath.length > 0){
			pathSegments = [reqPath];
		}
		//log.note( 'Request parser segments: ', pathSegments );
		
		// validate path segments
		for(let j=0; j < pathSegments.length; j++){	

			// append path segment and validate
			testPath = testPath.concat('/', pathSegments[j]);
			log.note('Testing for path', testPath + ext);
			
			// path segment is a valid module 
			if(fs.existsSync(testPath + ext)){
				result.isValid = true;
				result.path = testPath + ext;
				result.pathSegments = pathSegments.slice(0, j+1);
				result.searchSegments = pathSegments.slice(j+1);
				break;
			} 

			// path segment does not exist : exit
			else{
				result.isValid = false;
				result.pathFailed = testPath;
				result.searchSegments = pathSegments;
			}
		}

		// parse URL params from remaining path segments
		// append to URL object
		const search = result.searchSegments;
		this.params = search; // Expose as positional array
		
		for(let i=0; i<search.length; i=i+2){
			const name = search[i];
			const val = (i+1 in search) ? search[i+1] : '1';
			this.reqURL.searchParams.append(name, val);
		}

		//log.note( 'Request parser result: ', result );
		//log.note( 'URL search params', this.reqURL.search );
		
		return result;
	}


	async importModule(filePath, ...rest){

		log.note( 'Attempting module import ...');
		try{
			const module =  await import(filePath)
			log.note('Module imported', path.basename(filePath));
			module.default(...rest);
		}
		catch(err){
			//err.message = `Error importing ${filePath}: ${err.message}`
			log.error('Module import failed', err);
			this.render.error(500, 'Server failed');
		}
	}


// ------------------------------------
// Static Files
// ------------------------------------

	getStaticDefaultFilename(){

		const filename = this.op.staticFiles.defaultFilename;

		return filename;
	}

	getStaticFilePath(file){

		const normalized = path.normalize(file);
		const filePath = path.join(this.paths.stat, normalized);

		if(!filePath.startsWith(this.paths.stat)){
			log.warn('Path traversal detected', filePath);

			return null;
		}

		return filePath;
	}
	

// ------------------------------------
// JS Modules
// ------------------------------------
	
	getModuleDefaultFilename(){
		let filename = this.op.jsModules.defaultFilename;
		filename = filename.replace(/(\.[^.]+)$/, ''); 
		return filename;
	}

	moduleReqParser( reqPath ){
		const ext = this.op.jsModules.ext;
		this.mod = this.reqParser( reqPath, this.paths.mod, ext );
	}

	async moduleImport(){
		if(this.mod.isValid){
			try{
				await this.importModule(this.mod.path, this);
			}
			catch{
				if(! this.server.res.headersSent){
					log.warn('JS module', this.mod.path, 'did not render any content');
					this.render.error(404, 'No content served');
				}
			}
		}
	}

	renderContent(file,vars){ 

		if(typeof file === 'string'){
			const normalized = path.normalize(file);
			const filePath = path.join(this.paths.mod, normalized);
			const ext = path.extname(filePath);
			const contentType = this.getContentType(ext);

			if(fs.existsSync(filePath) && contentType){
				log.note(`Reading content from [${filePath}]`);
				const fileContent = fs.readFileSync(filePath, 'utf8');

				let result = fileContent;
				if(vars){
					result = fileContent.replace(/{(.*?)}/g, (match, key)=>{

						return vars.hasOwnProperty(key) ? String(vars[key]) : match;
					});
				}

				switch(ext){
					case '.md':

						return marked.parse(result);

					case '.txt':
					case '.text':
					case '.html':

						return result;
				}
			}
			else{
				log.error(filePath, ext);
			}
		} 
	}

// ------------------------------------	
// HTTP Response Handlers
// ------------------------------------	
	
	render = {
		errorJSON: (errCode, obj)=>{

			log.error(errCode, obj);
			this.render.code(errCode);
			this.render.JSON(obj);
		},
		error: (errCode, errMsg, ...rest)=>{

			log.error(errCode, errMsg, ...rest);

			if(!http.STATUS_CODES.hasOwnProperty(errCode)){
				this.render.error(500, 'Not a valid HTTP status code');
			}
			else{
				this.server.res.statusCode = errCode 
				this.server.res.statusMessage = http.STATUS_CODES[ errCode ];
				const errType = this.op.errors.outputType;
				const errTitle = errCode +' '+ this.server.res.statusMessage

				if(errType === "html"){
					this.server.res.setHeader('Content-Type', 'text/html');
					const tpl = new Templates({ error: this.op.errorTemplate });
					tpl.get('error').setVars({
						title: errTitle,
						msg: errMsg
					});
					const html = tpl.merge('error');
					this.server.res.write(html, 'utf-8');
				}
				else{
					this.server.res.setHeader('Content-Type', 'text/plain');
					const txt = "${errTitle}\n${errMsg}"
					this.server.res.write(txt, 'utf-8');
				}
			}
			this.server.res.end();
			log.end();
		},
		FILE: (filePath)=>{
			
			if(typeof filePath !== 'string'){
				this.render.error(500, "PATH must be a string");
			}
			else{
				filePath = this.getStaticFilePath( filePath );
				if(!filePath){
					this.render.error(403, 'Access denied');

					return;
				}

				const ext = path.extname(filePath);
				const contentType = this.getContentType(ext);

				if(contentType === undefined){
					this.render.error(415, 'File must be a supported format');
				}
				else if(!fs.existsSync(filePath)){
					log.warn('File not found send error');
					this.render.error(404, 'File not found');
				}
				else{
					log.note('Rendering Static file', path.basename(filePath));
					
					// Set common security and caching headers
					const cacheHeader = this.op.staticFiles.cache || 'no-cache';
					this.server.res.setHeader('X-Content-Type-Options', 'nosniff');
					this.server.res.setHeader('Cache-Control', cacheHeader);

					if(ext === '.md'){
						const fileContent = fs.readFileSync(filePath, {encoding: 'utf-8'});
						this.render.MARKDOWN(fileContent);
						return;
					}

					this.server.res.setHeader('Content-Type', contentType);
					const readStream = fs.createReadStream(filePath);
					readStream.on('open', ()=>{
						readStream.pipe(this.server.res);
					})
					readStream.on('error', (err)=>{
						log.error('Stream error', err);
						this.render.error(500, 'Server error');
					})
					readStream.on('end', ()=>{
						// log.end handled lazily
					});
				}
			}
		},
		JSON: (obj)=>{

			if(typeof obj !== 'object' || Array.isArray(obj)){
				this.render.error(500, "JSON must be an object");
			}
			else{
				log.note('Rendering JSON object');
				this.server.res.setHeader('Content-Type', 'application/json');
				const content = JSON.stringify(obj, null, 0);
				this.server.res.write(content, 'utf-8');
				this.server.res.end();
			}
		},
		TEXT: (txt)=>{

			if(typeof txt !== 'string'){
				this.render.error(500, "TEXT must be a string");
			}
			else{
				log.note('Rendering TEXT');
				this.server.res.setHeader('Content-Type', 'text/plain');
				this.server.res.write(txt, 'utf-8');
				this.server.res.end();
			}
		},
		HTML: (html)=>{

			if(typeof html !== 'string'){
				this.render.error( 500, "HTML must be a string" );
			}
			else{
				log.note('Rendering HTML');
				this.server.res.setHeader('Content-Type', 'text/html');
				this.server.res.write(html, 'utf-8');
				this.server.res.end();
				log.end();
			}
		},
		MARKDOWN: (md)=>{

			if(typeof md !== 'string'){
				this.render.error(500, "MARKDOWN must be a string");
			}
			else{
				log.note('Parsing MARKDOWN' );
				//this.server.res.setHeader( 'Content-Type', 'text/markdown');
				//this.server.res.write( md, 'utf-8');
				//this.server.res.end( log.end());
				const html = marked.parse(md);
				this.render.HTML(html);
			}
		}
	}
}
