import {
  Express,
  RequestHandler,
  Router,
  static as staticMiddleware,
} from 'express';
import { parse as parseUrl } from 'url';

import {
  bodyParser,
  constants,
  cookieParser,
  cors,
  express,
  fsPath,
  IWebApp,
  log,
  next,
  React,
} from './common';
import { fileHandler } from './handler.file';
import { redirectHandler } from './handler.redirect';
import { IWebAppOptions, RedirectPath } from './types';

/**
 * A web-application configuration.
 */
export class WebApp implements IWebApp {
  public static init(options: IWebAppOptions = {}) {
    return new WebApp(options);
  }

  public isStarted = false;
  public readonly dev: boolean;
  public readonly port: number;
  public readonly silent: boolean;
  public readonly staticPath: string;
  public readonly dir: string;
  public readonly router = Router();

  private _server: {
    express: Express;
    next: next.Server;
  };
  private _render: Array<{ urlPath: string; pagePath?: string }> = [];

  private constructor(options: IWebAppOptions) {
    // Store default values.
    this.dev = optionValue<boolean>('dev', constants.IS_DEV, options);
    this.port = optionValue<number>('port', 3000, options);
    this.silent = optionValue<boolean>('silent', false, options);
    this.staticPath = optionValue<string>('static', './static', options);
    this.dir = optionValue<string>('dir', './lib', options);
  }

  private get server() {
    if (!this._server) {
      const nextApp = next({ dev: this.dev, dir: this.dir });
      const expressApp = express
        .app()
        .use(bodyParser.json({}))
        .use(cookieParser())
        .use(staticMiddleware(fsPath.resolve(this.staticPath)))
        .use(cors({}))
        .use(this.router);
      this._server = {
        express: expressApp,
        next: nextApp,
      };
    }
    return this._server;
  }

  private restHandler(verb: 'get' | 'put' | 'post' | 'delete') {
    return (url: string, handler: RequestHandler) => {
      this.router[verb](url, handler);
      return this;
    };
  }

  public get = this.restHandler('get');
  public put = this.restHandler('put');
  public post = this.restHandler('post');
  public delete = this.restHandler('delete');

  public use(...handlers: Array<RequestHandler | IWebApp>) {
    // Merge in express-handlers.
    handlers
      .filter(item => !(item instanceof WebApp))
      .map(item => item as RequestHandler)
      .forEach(handler => this.router.use(handler));

    // Merge in another web-app's router and render mappings.
    handlers
      .filter(item => item instanceof WebApp)
      .map(app => app as WebApp)
      .forEach(app => {
        this.router.use(app.router);
        this._render = [...this._render, ...app._render];
      });

    return this;
  }

  public redirect(fromPath: string, toPath: RedirectPath | string) {
    return this.use(redirectHandler(fromPath, toPath));
  }

  public file(urlPath: string, filePath: string) {
    return this.use(fileHandler(urlPath, filePath));
  }

  public static(urlPath: string, dirPath: string) {
    const middleware = staticMiddleware(fsPath.resolve(dirPath));
    this.router.use(urlPath, middleware);
    return this;
  }

  public render(urlPath: string, pagePath?: string) {
    this._render = [...this._render, { urlPath, pagePath }];
    return this;
  }

  public async start(port?: number) {
    // Setup initial conditions.
    if (this.isStarted) {
      return this;
    }
    this.isStarted = true;

    // Setup custom URL routes that render NextJS pages.
    this._render.forEach(({ urlPath, pagePath }) => {
      this.router.get(urlPath, (req, res) => {
        const url = parseUrl(req.url, true);
        const pathname = pagePath || urlPath;
        const query = { ...req.params, ...req.query };
        this.server.next.render(req, res, pathname, query, url);
      });
    });

    // Prepare the NextJS app.
    const handle = this.server.next.getRequestHandler();
    this.router.get('*', (req, res) => handle(req, res));
    await this.server.next.prepare();

    // Start the express server.
    port = port === undefined ? this.port : port;
    await listen(this, this.server.express, port);
    return this;
  }
}

/**
 * INTERNAL
 */
function optionValue<T>(
  key: keyof IWebAppOptions,
  defaultValue: T,
  options: IWebAppOptions,
): T {
  return options && options[key] !== undefined
    ? (options[key] as any)
    : defaultValue;
}

const listen = (app: WebApp, express: Express, port: number) => {
  return new Promise((resolve, reject) => {
    express.listen(port, (err: Error) => {
      if (err) {
        reject(err);
      } else {
        logStarted(app, port);
        resolve();
      }
    });
  });
};

const logStarted = (app: WebApp, port: number) => {
  if (app.silent) {
    return;
  }

  // Log application details.
  const PACKAGE = require(fsPath.resolve('./package.json'));
  const LIB_PACKAGE = require(fsPath.join(__dirname, '../../package.json'));
  log.info(`> Ready on ${log.cyan('localhost')}:${log.magenta(port)}`);
  log.info();
  log.info.gray(`  name:    ${log.white(PACKAGE.name)}@${PACKAGE.version}`);
  log.info.gray(`  dev:     ${app.dev}`);
  log.info.gray(`  dir:     ${app.dir}`);
  log.info.gray(`  static:  ${app.staticPath}`);
  log.info.gray(`  react:   ${React.version}`);
  log.info.gray(`  next:    ${LIB_PACKAGE.dependencies.next}`);

  // Log routes.
  const routes = express.routes(app.router);
  if (routes.length > 0) {
    log.info();
    log.info.cyan(`  Routes:`);
    routes
      .map(route => route.methods.map(method => ({ method, path: route.path })))
      .reduce((acc, next) => [...acc, ...next], [])
      .forEach(route => {
        const method = `${route.method}     `.substr(0, 8);
        log.info(`  ${log.magenta(method)} ${route.path}`);
      });
  }

  // Finish up.
  log.info();
};
