/**
 * See:
 *    https://github.com/pillarjs/path-to-regexp
 */
import { parse as parseUrl } from 'url';

import { queryString } from '../queryString';
import { value as valueUtil } from '../value';
import { pathToRegex } from './libs';
import {
  IRoute,
  IRouteMeta,
  IRouteToken,
  RouteOptions,
  RouteParams,
  RouteQuery,
} from './types';

export * from './types';

export type CreateRoute = <P extends RouteParams, Q extends RouteQuery = {}>(
  path: string,
  args?: RouteOptions,
) => Route<P, Q>;

/**
 * Represents a URL route.
 */
export class Route<P extends RouteParams = {}, Q extends RouteQuery = {}>
  implements IRoute {
  public static create: CreateRoute = (path, args) => new Route(path, args);

  public static get: CreateRoute = (path, args) =>
    Route.create(path, { ...args, method: 'GET' });
  public static put: CreateRoute = (path, args) =>
    Route.create(path, { ...args, method: 'PUT' });
  public static post: CreateRoute = (path, args) =>
    Route.create(path, { ...args, method: 'POST' });
  public static delete: CreateRoute = (path, args) =>
    Route.create(path, { ...args, method: 'DELETE' });
  public static patch: CreateRoute = (path, args) =>
    Route.create(path, { ...args, method: 'PATCH' });

  public readonly path: IRoute['path'];

  private readonly _options: RouteOptions;
  private readonly _tokens: pathToRegex.Token[];
  private readonly _regex: RegExp;
  private readonly _toPath: pathToRegex.PathFunction;

  private constructor(path: string, options: RouteOptions = {}) {
    path = (path || '').trim();
    if (!path) {
      throw new Error(`A route pattern is required.`);
    }

    // Arguments from:
    //    https://github.com/pillarjs/path-to-regexp#usage
    const sensitive = valueUtil.defaultValue(options.caseSensitive, true);
    const strict = valueUtil.defaultValue(options.strict, true);
    const end = valueUtil.defaultValue(options.end, true);
    const start = valueUtil.defaultValue(options.start, true);
    const delimiter = valueUtil.defaultValue(options.delimiter, '/');
    const endsWith = valueUtil.defaultValue(options.endsWith, undefined);
    const delimiters = valueUtil.defaultValue(options.delimiters, ['./']);

    this.path = path;
    // this.method = method;
    this._options = options;
    this._regex = pathToRegex(path, [], {
      sensitive,
      strict,
      end,
      start,
      delimiter,
      endsWith,
      delimiters,
    });
    this._tokens = pathToRegex.parse(path);
    this._toPath = pathToRegex.compile(path);
  }

  public toString() {
    return this.path;
  }

  /**
   * Retrieve the origin domain for the route.
   */
  // public get origin() {
  //   const factory = this._origin;
  //   const args: IOriginArgs = {
  //     env: IS_DEV ? 'DEV' : 'PROD',
  //     isProd: !IS_DEV,
  //     isDev: IS_DEV,
  //     isBrowser: IS_BROWSER,
  //     isServer: !IS_BROWSER,
  //   };
  //   return factory ? factory(args) || '' : '';
  // }

  /**
   * Names of the types used by the route for TS => JSON-Schema conversion.
   */
  public get schema(): IRoute['schema'] {
    return this._options.schema || {};
  }

  /**
   * The HTTP method of the route.
   */
  public get method(): IRoute['method'] {
    return this._options.method || 'GET';
  }

  /**
   * The title (used for documentation)
   */
  public get title(): IRoute['title'] {
    return this._options.title;
  }

  /**
   * The summary description of the route.
   */
  public get description(): IRoute['description'] {
    return this._options.description;
  }

  /**
   * URL to documentation for the route.
   */
  public get docs(): IRoute['docs'] {
    return this._options.docs;
  }

  /**
   * URL to documentation for the route.
   */
  public get meta(): IRouteMeta {
    return this._options.meta || {};
  }

  /**
   * The set of variable-tokens within the route.
   */
  public get tokens(): IRoute['tokens'] {
    const toObj = (token: pathToRegex.Key) => {
      const { name, prefix, delimiter, optional, repeat, partial } = token;
      const res: IRouteToken = {
        name,
        prefix,
        delimiter,
        optional,
        repeat,
        partial,
      };
      return res;
    };
    return (this._tokens || []).map(token => {
      return typeof token === 'string' ? token : toObj(token);
    });
  }

  /**
   * Determines whether the given URL matches the route pattern.
   */
  public isMatch(url?: string) {
    return Boolean(this._regex.exec(url || ''));
  }

  /**
   * Extracts the set of parameter values from the given URL.
   */
  public params(url?: string): P {
    return Route.params(this, url);
  }
  public static params<P extends RouteParams = {}>(
    route: Route,
    url?: string,
  ): P {
    const params = {} as any;
    if (!url) {
      return params;
    }
    const tokens = route.tokens;
    const matches = route._regex.exec(url);
    if (!matches) {
      return params;
    }
    matches.forEach((value, i) => {
      value = value || '';
      const token = tokens[i];
      if (typeof token === 'object') {
        value = value.split('?')[0];
        params[token.name] = valueUtil.toType(value);
      }
    });
    return params;
  }

  /**
   * Extracts the query-string from the given url.
   *
   * Note:
   *    Empty values are converted to flags.
   *
   *      - "/foo?force"        // <== true  (implicit)
   *      - "/foo?force=true"   // <== true  (explicit)
   *      - "/foo?force=false"  // <== false (explicit)
   *      - "/foo"              // <== false, query flag undefined.
   *
   * Example:
   *
   *      const force = asValueFlag(req.query.force)
   *
   */
  public query(url?: string): Q {
    return Route.query(url);
  }
  public static query<Q extends RouteQuery = {}>(url?: string): Q {
    const query: any = url ? parseUrl(url, true).query : {};
    Object.keys(query).forEach(key => {
      let value = query[key];
      value = value === '' ? queryString.valueAsFlag(value) : value;
      value = valueUtil.toType(value);
      query[key] = value;
    });
    return query;
  }

  /**
   * Retrieves a parsed URL.
   */
  public url(url: string = '', options: { origin?: string } = {}): RouteUrl {
    const { origin } = options;
    return new RouteUrl<P, Q>({ url, route: this, origin });
  }

  /**
   * Converts the set of params to a URL.
   */
  public toUrl(args: { params?: P; query?: Q; origin?: string }) {
    const { params = {}, query = {}, origin } = args;
    let url = this._toPath(params);
    const q = Route.toQueryString(query);
    if (q) {
      url = url.includes('?') ? `${url}&${q}` : `${url}?${q}`;
    }
    return this.url(url, { origin });
  }

  /**
   * Converts an object to a formatted query-string.
   */
  public static toQueryString(query?: RouteQuery) {
    const toQuery = (
      key: string,
      value?: string | string[] | number | boolean,
    ) => {
      value = value ? encodeURI(value.toString()) : value;
      return value ? `${key}=${value}` : key;
    };

    const res = query
      ? Object.keys(query || {})
          .filter(key => Boolean(query[key]))
          .map(key => toQuery(key, query[key]))
      : [];
    return res.join('&');
  }

  /**
   * Creates a clone of the route, overriding values.
   */
  public clone(options: { path?: string } & RouteOptions = {}) {
    return Route.create(options.path || this.path, {
      ...this._options,
      ...options,
    });
  }

  public toObject(): IRoute {
    return {
      path: this.path,
      method: this.method,
      title: this.title,
      description: this.description,
      docs: this.docs,
      schema: this.schema,
      tokens: this.tokens,
    };
  }

  /**
   * Walks an object calling the given function for each Route
   * object that is found.
   *
   * NOTE:
   *    This is use when building up index-objects of routes
   *    that you wish to examine as a set.
   */
  public static walk(
    tree: object | undefined,
    fn: (route: Route, args: { stop: () => void }) => void,
  ) {
    if (!tree) {
      return;
    }
    let stopped = false;

    for (const key of Object.keys(tree)) {
      const value = tree[key];
      if (tree[key] instanceof Route) {
        fn(value, { stop: () => (stopped = true) });
      } else {
        if (typeof value === 'object') {
          this.walk(value, fn); // <== RECURSION
        }
      }
      if (stopped) {
        return;
      }
    }
  }

  /**
   * Walks the tree looking for the first match.
   */
  public static find(
    tree: object | undefined,
    match: (route: Route) => boolean,
  ) {
    if (!tree) {
      return;
    }
    let result: Route | undefined;
    Route.walk(tree, (route, e) => {
      if (match(route) === true) {
        result = route;
        e.stop();
      }
    });
    return result;
  }

  /**
   * Maps over an object containing an index of routes.
   */
  public static map<T>(
    tree: object | undefined,
    fn: (route: Route, index: number) => T,
  ) {
    let result: T[] = [];
    Route.walk(
      tree,
      route => (result = [...result, fn(route, result.length - 1)]),
    );
    return result;
  }

  public static toString(path: string, options: { origin?: string } = {}) {
    let res = path;
    if (options.origin) {
      res = `${options.origin.replace(/\/*$/, '')}/${res.replace(/^\//, '')}`;
    }
    return res;
  }
}

/**
 * Represents a specific URL.
 */
export class RouteUrl<P extends RouteParams = {}, Q extends RouteQuery = {}> {
  public readonly path: string;
  public readonly route: Route;
  public readonly params: P;
  public readonly query: Q;
  public readonly origin: string | undefined;

  constructor(args: { url: string; route: Route<P, Q>; origin?: string }) {
    const { url, route, origin = '' } = args;
    this.path = url;
    this.route = route;
    this.params = Route.params<P>(route, url);
    this.query = Route.query<Q>(url);
    this.origin = origin.trim() ? origin.replace(/\/*$/, '') : undefined;
  }

  public get s() {
    return this.toString();
  }

  public toString(options: { origin?: string } = {}) {
    const origin = options.origin || this.origin;
    return Route.toString(this.path, { ...options, origin });
  }

  /**
   * Checks a set of keys within the URL's query-string to see
   * if any of them are flags.
   *
   * For example:
   *
   *      url.hasFlag(['f', 'force']);
   *
   */
  public hasFlag(key?: string | string[]) {
    return queryString.isFlag(key, this.query);
  }
}
