import React from 'react';
import express, { Application, Request, Response } from 'express';
import cookieParser from 'cookie-parser';
import { v1 as uuidv1 } from 'uuid';
import ReactDOMServer from 'react-dom/server';
import api from './api';
import path from 'path';
import fs from 'fs';
import { buildOrisonDirectory } from './build';
import { Configuration } from 'webpack';
import * as devServer from './dev-server';
import WebSocketService from './websocket';
import expressWs from 'express-ws';
import OrisonStyleContext, { IOrisonStyleContext } from './styles';

export interface SessionState {
    sessionToken: string;
    [key: string]: any;
}

export type SessionRequest = Request & {
    session: SessionState;
}

export interface OrisonSettings {
    /**
     * returns the default session state created for new sessions
     */
    sessionStateGenerator: (req: Request) => Omit<SessionState, 'sessionToken'>;
    /**
     * returns the webpack configuration for compilation, given the default one
     */
    rewebpack?: (options: Configuration, transformer?: 'server' | 'client') => Configuration;
    /**
     * true if the server is in production mode
     */
    production?: boolean;
    /**
     * a list of routes which will be rendered server-side only and will not be hydrated client-side
     */
    skipHydration?: string[];
    /**
     * whether or not to statically render the master page and its child page before passing it to the `App` on the server-side.
     * If this is `true`, the body passed to the `App` is a `div` containing the static HTML string.
     * If this is `false,` the body passed is a `React.ReactElement` containing the React component for the body. 
     * Note that if this is `false` the body will be statically rendered twice: one phase for the server-side CSS generation, and another phase for the App.
     * 
     * @default true
     */
    preRenderBody?: boolean;
}

export class OrisonServer {
    private server: expressWs.Instance;
    private sessions: SessionState[] = [];
    private settings: OrisonSettings | null = null;
    private modules: Record<string, any> = {};
    private pageDependencies: Record<string, string[]> = {};
    private webSocketService: WebSocketService = new WebSocketService();

    public constructor() {
        this.server = expressWs(express());
    }

    public setModuleDefinition(path: string, module: any) {
        this.modules[path] = module;
    }

    public setModuleDependencies(path: string, deps: string[]) {
        this.pageDependencies[path] = deps;
    }

    public getSettings(): OrisonSettings | null {
        return this.settings;
    }

    public getSessions(): SessionState[] {
        return this.sessions;
    }

    public getWebSocketService(): WebSocketService {
        return this.webSocketService;
    }

    /**
     * Initializes the Orison server instance using the given settings, builds the server and client files with Webpack, initializes all API routes, and creates all Express request listeners.
     * 
     * @param settings The settings for the Orison server.
     */
    public async configure(settings: OrisonSettings) {
        if (settings.production) {
            process.env.NODE_ENV = 'production';
        } else {
            process.env.NODE_ENV = 'development';
        }
        if (settings.preRenderBody === undefined) {
            settings.preRenderBody = true;
        }
        const buildResult = await buildOrisonDirectory(settings);

        api.initialize(this);

        this.server.app.use('*', cookieParser());
        this.server.app.use('*', (req, res, next) => {
            try {
                const toSessionRequest = (session: SessionState) => {
                    const sr = req as any as SessionRequest;
                    sr.session = session;
                }

                const addSession = () => {
                    const token = uuidv1();
                    let expires = new Date();
                    expires.setMonth(expires.getMonth() + 1);
                    res.cookie('__ORISON_TOKEN', token, {
                        httpOnly: true,
                        expires
                    });

                    const session: SessionState = {
                        sessionToken: token,
                        ...this.settings!.sessionStateGenerator(req)
                    };
                    this.sessions.push(session);
                    toSessionRequest(session);
                    next();
                };

                if (typeof req.cookies.__ORISON_TOKEN !== 'string') {
                    addSession();
                } else {
                    const session = this.sessions.find(session => session.sessionToken === req.cookies.__ORISON_TOKEN);
                    if (session === undefined) {
                        addSession();
                    } else {
                        toSessionRequest(session);
                        next();
                    }
                }
            } catch (e) {
                console.log(e);
                if (!res.headersSent) {
                    res.sendStatus(500);
                }
            }
        });

        this.webSocketService.initialize();
        this.server.app.ws('/websocket', (ws, req) => {
            this.webSocketService.handleConnection(this, ws, req as SessionRequest);
        });

        const appFunction: OrisonApp = require(path.relative(__dirname, path.join(process.cwd(), '.orison', 'pages', '_app_server'))).default;
        let masterFunction: OrisonPage<any> | null = null;
        const mfp = path.join(process.cwd(), '.orison', 'pages', '_master_server');
        if (fs.existsSync(mfp + '.js')) {
            masterFunction = require(path.relative(__dirname, mfp)).default;
        }
        this.pageDependencies = buildResult.pageDependencies;
        for (const page of buildResult.pages) {
            if (page.path === '/_master' || page.path === '/_app') {
                continue;
            }
            const module = require(path.relative(__dirname, path.join(process.cwd(), '.orison', 'pages', page.serverScriptPath + '_server')));
            const loaded = module.default;
            this.setModuleDefinition(page.path, loaded);
        }
        for (const page of buildResult.pages) {
            this.server.app.get(page.path, async (req, res) => {
                const loaded = this.modules[page.path];
                const isStatic = page.clientScriptPath === null;

                let props = {};
                if (typeof loaded.getServerSideProps === 'function') {
                    props = await loaded.getServerSideProps(req as any as SessionRequest, res);
                    if (res.headersSent) {
                        return;
                    }
                    if (props === undefined) {
                        throw new Error('No props were returned, but the connection was not closed');
                    }
                }

                let masterProps = {};
                if (masterFunction !== null && typeof masterFunction.getServerSideProps === 'function') {
                    masterProps = await masterFunction.getServerSideProps(req as any as SessionRequest, res);
                    if (res.headersSent) {
                        return;
                    }
                    if (masterProps === undefined) {
                        throw new Error('No props were returned, but the connection was not closed');
                    }
                }

                let subElement: React.ReactElement = React.createElement(loaded, props);
                if (masterFunction !== null) {
                    subElement = React.createElement(masterFunction, masterProps, subElement);
                }
                const body = (
                    <>
                        <div id="root">{subElement}</div>
                        {!isStatic ? (
                            <>
                                {this.pageDependencies[page.clientScriptPath!]?.map(vendor => (<script key={vendor} src={'/__orison/' + vendor} />))}
                                <script src={'/__' + page.clientScriptPath!.substring(1)} />
                            </>
                        ) : (null)}
                    </>
                );

                const renderFunction = isStatic ? ReactDOMServer.renderToStaticMarkup : ReactDOMServer.renderToString;

                const context: IOrisonStyleContext = {
                    cssSources: new Set()
                };
                const renderedBody = renderFunction(
                    <OrisonStyleContext.Provider value={context}>
                        {body}
                    </OrisonStyleContext.Provider>
                );

                const head = (
                    <>
                        <script dangerouslySetInnerHTML={{
                            __html: 'window.__ORISON_PROPS=' + JSON.stringify(props) + ';window.__ORISON_MASTER_PROPS=' + JSON.stringify(masterProps) + ';'
                        }} />
                        {Array.from(context.cssSources).map((source, index) => (
                            <style key={index.toString()} dangerouslySetInnerHTML={{
                                __html: source
                            }} />
                        ))}
                    </>
                );

                const appProps: OrisonAppProps = {
                    head,
                    body: this.settings!.preRenderBody ? (
                        <div dangerouslySetInnerHTML={{
                            __html: renderedBody
                        }} />
                    ) : body
                };

                const html = appFunction({
                    render: renderFunction
                }, appProps);
                res.send(html);
            });
        }

        this.server.app.use('/__orison/pages/client', express.static('.orison/pages/client'));
        this.server.app.use('/', express.static('public'));

        this.settings = settings;

        if (!settings.production) {
            devServer.watchPages(this);
        }
    }

    /**
     * @returns The Express request handler used by Orison.
     */
    public getRequestListener(): Application {
        return this.server.app;
    }

    /**
     * Starts the server using the default `listen` function using `express`.
     * 
     * @param port The TCP port to bind the server to.
     */
    public start(port: number) {
        if (this.settings === null) {
            throw new Error('Attempt to start server before calling configure()');
        }
        this.server.app.listen(port, () => console.log('Started Orison server at http://localhost:' + port));
    }
}

export type OrisonPage<T = {}> = React.FC<T> & {
    getServerSideProps?: (req: SessionRequest, res: Response) => T | Promise<T> | void | Promise<void>;
};

export interface OrisonAppProps {
    head: React.ReactNode,
    body: React.ReactNode
};

export interface OrisonRenderer {
    /**
     * Renders a React element to an HTML string.
     */
    render: (element: React.ReactElement) => string;
}

export type OrisonApp = (renderer: OrisonRenderer, props: OrisonAppProps) => string;
