/**
 * Puppeteer + from-memory devServer rollup plugin to open the result in a webpage en output the result
 * (after an optional series of commands to the puppeteer Page)
 */


import {puppeteerRunTest, PageTestCallback, TestOutput} from "./puppeteer-run-test.ts";
import {isInDebugMode} from "./debug-mode.ts";

import {resolve, posix} from "node:path";
import fs from "node:fs/promises";
import type {Stats} from "node:fs";

import { createServer as createHttpsServer } from 'https'
import { createServer} from 'http'

import { Mime } from 'mime/lite'
import standardTypes from 'mime/types/standard.js'
import otherTypes from 'mime/types/other.js'


import type {NormalizedOutputOptions, OutputAsset, OutputBundle, OutputChunk, Plugin} from 'rollup';

import type {
    IncomingHttpHeaders, OutgoingHttpHeaders,
    IncomingMessage, ServerResponse,
    Server
} from 'http'
import type { ServerOptions } from 'https'

import test, {ExecutionContext} from "ava";
import {createReadStream} from "fs";


type TypeMap = {
    [key: string]: string[];
};

type ErrorCodeException = Error & {code: string};
export type TestResultCallback = (output: TestOutput)=>void;
export type LogCallback = (...args: string[])=>void;


export interface ServeTestOptions {
    /**
     * Change the path to be opened when the test is started
     * Remember to start with a slash, e.g. `'/different/page'`
     */
    path?: string


    /**
     * Fallback to serving from a specified srcDir, this allows setting breakpoints on sourcecode and test the sourcemaps
     */
    srcDir?: string|boolean;

    /**
     * A callback to manually take control of the page and simulate user interactions
     */
    cb?: PageTestCallback;

    /**
     * Set to `true` to return index.html (200) instead of error page (404)
     * or path to fallback page
     */
    historyApiFallback?: boolean | string

    /**
     * Change the host of the server (default: `'localhost'`)
     */
    host?: string

    /**
     * Change the port that the server will listen on (default: `10001`)
     */
    port?: number | string

    /**
     * By default server will be served over HTTP (https: `false`). It can optionally be served over HTTPS.
     */
    https?: ServerOptions | false

    /**
     * Set custom response headers
     */
    headers?:
        | IncomingHttpHeaders
        | OutgoingHttpHeaders
        | {
        // i.e. Parameters<OutgoingMessage["setHeader"]>
        [name: string]: number | string | ReadonlyArray<string>
    }

    /**
     * Set custom mime types, usage https://github.com/broofa/mime#mimedefinetypemap-force--false
     */
    mimeTypes?: TypeMap

    /**
     * Execute function after server has begun listening
     */
    onListening?: (server: Server) => void


}

export interface RollupServeTestOptions extends ServeTestOptions{
    /**
     * A callback to run when a test has been run
     */
    onResult?: TestResultCallback;

    /**
     * Callback to log messages
     */
    log?: LogCallback;
}


/**
 * Serve your rolled up bundle like webpack-dev-server
 * @param {import('..').RollupServeOptions} options
 */
export default function serveTest (options: RollupServeTestOptions ): Plugin {
    const mime = new Mime(standardTypes, otherTypes)
    const testOptions = {
        port: 0,
        headers: {},
        historyApiFallback: true,
        srcDir: '', // Serve source dir as fallback (for sourcemaps / debugging)
        onListening: function noop (){},
        ...options||{},
        https: options.https??false,
        mimeTypes: options.mimeTypes? mime.define(options.mimeTypes, true): false
    }

    let server : Server;
    let bundle : OutputBundle = {};

    const logTest = (msg: string, mode: 'info'|'warn' = 'info')=>{
        if(isInDebugMode()){
            console.log(msg);
        }
        const modeColor = {
            green: 32,
            info: 34,
            warn: 33,
        }[mode];
        testOptions.log?.(`\u001b[${modeColor}m${msg}\u001b[0m`);
    }

    const requestListener = async (request: IncomingMessage, response: ServerResponse) => {
        // Remove querystring
        const unsafePath = decodeURI(request.url!.split('?')[0])

        // Don't allow path traversal
        const urlPath = posix.normalize(unsafePath)

        for(const [key, value] of Object.entries((<OutgoingHttpHeaders>testOptions.headers))){
            response.setHeader(key, value!);
        }

        function urlToFilePath(url:string){
            return url[0]==='/'?url.slice(1):url;
        }
        let filePath = urlToFilePath(urlPath);
        let absPath: string | undefined = undefined;
        let stats: Stats | undefined = undefined;

        if(!bundle[filePath]){
            if(testOptions.srcDir || testOptions.srcDir===''){
                try{
                    absPath = resolve(<string>testOptions.srcDir||'',filePath);
                    stats = await fs.stat(absPath);
                }catch(err){
                    // File not found
                }
            }
            if(!(stats?.isFile()) && testOptions.historyApiFallback) {
                const  fallbackPath = typeof testOptions.historyApiFallback === 'string'
                    ? testOptions.historyApiFallback
                    : '/index.html';
                if(bundle[urlToFilePath(fallbackPath)]){
                    filePath = urlToFilePath(fallbackPath);
                }
            }
        }

        const mimeType = mime.getType(filePath!);
        if(bundle[filePath]) {
            let file: OutputChunk | OutputAsset = bundle[filePath];
            const content = (<OutputChunk>file).code || (<OutputAsset>file).source; // Todo might need to read a source file;
            response.writeHead(200, {'Content-Type': mimeType || 'text/plain'});
            response.end(content, 'utf-8');
            logTest(`[200] ${request.url}`);
            return;
        }else if(stats?.isFile()){
            response.writeHead(200, {
                'Content-Type': mimeType || 'text/plain',
                'Content-Length': stats.size,
                'Last-Modified': stats.mtime.toUTCString()
            });
            const content = await fs.readFile(absPath!);
            response.end(content);
            response.end();
            logTest(`[200] ${request.url} (src)`);
        }else{
            response.writeHead(404)
            response.end(
                '404 Not Found' + '\n\n' + filePath,
                'utf-8'
            )

            logTest(`[404] ${request.url}`, "warn");
            return;
        }
    }


    function closeServerOnTermination () {
        const terminationSignals = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP']
        terminationSignals.forEach(signal => {
            process.on(signal, () => {
                if (server) {
                    server.close()
                    process.exit()
                }
            })
        })
    }

    // release previous server instance if rollup is reloading configuration in watch mode
    // @ts-ignore
    if (server) {
        server.close()
    } else {
        closeServerOnTermination()
    }

    // If HTTPS options are available, create an HTTPS server
    server = testOptions.https
        ? createHttpsServer(testOptions.https, requestListener)
        : createServer(requestListener)
    server.listen(
        typeof(testOptions.port)==='string'? Number.parseInt(testOptions.port):testOptions.port,
        testOptions.host,
        undefined,
        () => testOptions.onListening?.(server)
    )

    testOptions.port = (<any>server.address())?.port ?? testOptions.port;

    // Assemble url for error and info messages
    const url = (testOptions.https ? 'https' : 'http') + '://' + (testOptions.host || 'localhost') + ':' + testOptions.port

    // Handle common server errors
    server.on('error', e  => {
        if ((<ErrorCodeException>e).code === 'EADDRINUSE') {
            console.error(url + ' is in use, either stop the other server or use a different port.')
            process.exit()
        } else {
            throw e
        }
    })

    let first = true

    return {
        name: 'serve',
        generateBundle: {
            order: 'post',
            async handler(options, output){
                bundle = output;
                if (first) {
                    first = false

                    const testOutput = await puppeteerRunTest({
                        ...testOptions
                    }, url);

                    testOptions.onResult?.(testOutput);
                }
            }
        },
        closeBundle (){
            // Done with the bundle
        }
    }
}
