'use strict';

var validator = new (require('jsonschema').Validator)();
var recase = require('recase').create({});

var portSchema = { type: 'number', minimum: 1, maximum: 65535 };

var moduleSchemas = {
  // the proxy module is common to basically all categories.
  proxy: {
    type: 'object'
  , oneOf: [
      { required: [ 'address' ] }
    , { required: [ 'port' ] }
    ]
  , properties: {
      address: { type: 'string' }
    , host:    { type: 'string' }
    , port:    portSchema
    }
  }

  // redirect and static modules are for HTTP
, redirect: {
    type: 'object'
  , required: [ 'to', 'from' ]
  , properties: {
      to:     { type: 'string'}
    , from:   { type: 'string'}
    , status: { type: 'integer', minimum: 1, maximum: 999 }
  , }
  }
, static: {
    type: 'object'
  , required: [ 'root' ]
  , properties: {
      root: { type: 'string' }
    }
  }

  // the acme module is for TLS
, acme: {
    type: 'object'
  , required: [ 'email' ]
  , properties: {
      email:          { type: 'string' }
    , server:         { type: 'string' }
    , challenge_type: { type: 'string' }
    }
  }

  // the dns control modules for DDNS
, 'dns@oauth3.org': {
    type: 'object'
  , required: [ 'token_id' ]
  , properties: {
      token_id: { type: 'string' }
    }
  }
};
// forward is basically the same as proxy, but specifies the relevant incoming port(s).
// only allows for the raw transport layers (TCP/UDP)
moduleSchemas.forward = JSON.parse(JSON.stringify(moduleSchemas.proxy));
moduleSchemas.forward.required = [ 'ports' ];
moduleSchemas.forward.properties.ports = { type: 'array', items: portSchema };

Object.keys(moduleSchemas).forEach(function (name) {
  var schema = moduleSchemas[name];
  schema.id = '/modules/'+name;
  schema.required = ['id', 'type'].concat(schema.required || []);
  schema.properties.id   = { type: 'string' };
  schema.properties.type = { type: 'string', const: name };
  validator.addSchema(schema, schema.id);
});

function toSchemaRef(name) {
  return { '$ref': '/modules/'+name };
}
var moduleRefs = {
  http: [ 'proxy', 'static', 'redirect' ].map(toSchemaRef)
, tls:  [ 'proxy', 'acme' ].map(toSchemaRef)
, tcp:  [ 'forward' ].map(toSchemaRef)
, udp:  [ 'forward' ].map(toSchemaRef)
, ddns: [ 'dns@oauth3.org' ].map(toSchemaRef)
};

function addDomainRequirement(itemSchema) {
  itemSchema.required = (itemSchema.required || []).concat('domains');
  itemSchema.properties = itemSchema.properties || {};
  itemSchema.properties.domains = { type: 'array', items: { type: 'string' }, minLength: 1};
  return itemSchema;
}

var domainSchema = {
  type: 'array'
, items: {
    type: 'object'
  , properties: {
      id:      { type: 'string' }
    , names:   { type: 'array', items: { type: 'string' }, minLength: 1}
    , modules: {
        type: 'object'
      , properties: {
          tls:  { type: 'array', items: { oneOf: moduleRefs.tls }}
        , http: { type: 'array', items: { oneOf: moduleRefs.http }}
        , ddns: { type: 'array', items: { oneOf: moduleRefs.ddns }}
        }
      , additionalProperties: false
      }
    }
  }
};

var httpSchema = {
  type: 'object'
, properties: {
    modules: { type: 'array', items: addDomainRequirement({ oneOf: moduleRefs.http }) }

    // These properties should be snake_case to match the API and config format
  , primary_domain: { type: 'string' }
  , allow_insecure: { type: 'boolean' }
  , trust_proxy:    { type: 'boolean' }

    // these are forbidden deprecated settings.
  , bind:    { not: {} }
  , domains: { not: {} }
  }
};

var tlsSchema = {
  type: 'object'
, properties: {
    modules: { type: 'array', items: addDomainRequirement({ oneOf: moduleRefs.tls }) }

    // these are forbidden deprecated settings.
  , acme:    { not: {} }
  , bind:    { not: {} }
  , domains: { not: {} }
  }
};

var tcpSchema = {
  type: 'object'
, required: [ 'bind' ]
, properties: {
    bind:    { type: 'array', items: portSchema, minLength: 1 }
  , modules: { type: 'array', items: { oneOf: moduleRefs.tcp }}
  }
};

var udpSchema = {
  type: 'object'
, properties: {
    bind:    { type: 'array', items: portSchema }
  , modules: { type: 'array', items: { oneOf: moduleRefs.udp }}
  }
};

var mdnsSchema = {
  type: 'object'
, required: [ 'port', 'broadcast', 'ttl' ]
, properties: {
    port:      portSchema
  , broadcast: { type: 'string' }
  , ttl:       { type: 'integer', minimum: 0, maximum: 2147483647 }
  }
};

var ddnsSchema = {
  type: 'object'
, properties: {
    loopback: {
      type: 'object'
    , required: [ 'type', 'domain' ]
    , properties: {
        type:   { type: 'string', const: 'tunnel@oauth3.org' }
      , domain: { type: 'string'}
      }
    }
  , tunnel: {
      type: 'object'
    , required: [ 'type', 'token_id' ]
    , properties: {
        type:  { type: 'string', const: 'tunnel@oauth3.org' }
      , token_id: { type: 'string'}
      }
    }
  , modules: { type: 'array', items: { oneOf: moduleRefs.ddns }}
  }
};
var socks5Schema = {
  type: 'object'
, properties: {
    enabled: { type: 'boolean' }
  , port:    portSchema
  }
};
var deviceSchema = {
  type: 'object'
, properties: {
    hostname: { type: 'string' }
  }
};

var mainSchema = {
  type: 'object'
, required: [ 'domains', 'http', 'tls', 'tcp', 'udp', 'mdns', 'ddns' ]
, properties: {
    domains:domainSchema
  , http:   httpSchema
  , tls:    tlsSchema
  , tcp:    tcpSchema
  , udp:    udpSchema
  , mdns:   mdnsSchema
  , ddns:   ddnsSchema
  , socks5: socks5Schema
  , device: deviceSchema
  }
, additionalProperties: false
};

function validate(config) {
  return validator.validate(recase.snakeCopy(config), mainSchema).errors;
}
module.exports.validate = validate;

class IdList extends Array {
  constructor(rawList) {
    super();
    if (Array.isArray(rawList)) {
      Object.assign(this, JSON.parse(JSON.stringify(rawList)));
    }
    this._itemName = 'item';
  }

  findId(id) {
    return Array.prototype.find.call(this, function (dom) {
      return dom.id === id;
    });
  }

  add(item) {
    item.id = require('crypto').randomBytes(4).toString('hex');
    this.push(item);
  }

  update(id, update) {
    var item = this.findId(id);
    if (!item) {
      var error = new Error("no "+this._itemName+" with ID '"+id+"'");
      error.statusCode = 404;
      throw error;
    }
    Object.assign(this.findId(id), update);
  }

  remove(id) {
    var index = this.findIndex(function (dom) {
      return dom.id === id;
    });
    if (index < 0) {
      var error = new Error("no "+this._itemName+" with ID '"+id+"'");
      error.statusCode = 404;
      throw error;
    }
    this.splice(index, 1);
  }
}
class ModuleList extends IdList {
  constructor(rawList) {
    super(rawList);
    this._itemName = 'module';
  }

  add(mod) {
    if (!mod.type) {
      throw new Error("module must have a 'type' defined");
    }
    if (!moduleSchemas[mod.type]) {
      throw new Error("invalid module type '"+mod.type+"'");
    }

    mod.id = require('crypto').randomBytes(4).toString('hex');
    this.push(mod);
  }
}
class DomainList extends IdList {
  constructor(rawList) {
    super(rawList);
    this._itemName = 'domain';
    this.forEach(function (dom) {
      dom.modules = {
        http: new ModuleList((dom.modules || {}).http)
      , tls:  new ModuleList((dom.modules || {}).tls)
      , ddns: new ModuleList((dom.modules || {}).ddns)
      };
    });
  }

  add(dom) {
    if (!Array.isArray(dom.names) || !dom.names.length) {
      throw new Error("domains must have a non-empty array for 'names'");
    }
    if (dom.names.some(function (name) { return typeof name !== 'string'; })) {
      throw new Error("all domain names must be strings");
    }

    var modLists = {
      http: new ModuleList()
    , tls:  new ModuleList()
    , ddns: new ModuleList()
    };
    // We add these after instead of in the constructor to run the validation and manipulation
    // in the ModList add function since these are all new modules.
    if (dom.modules) {
      Object.keys(modLists).forEach(function (key) {
        if (Array.isArray(dom.modules[key])) {
          dom.modules[key].forEach(modLists[key].add, modLists[key]);
        }
      });
    }

    dom.id = require('crypto').randomBytes(4).toString('hex');
    dom.modules = modLists;
    this.push(dom);
  }
}

class ConfigChanger {
  constructor(start) {
    Object.assign(this, JSON.parse(JSON.stringify(start)));
    delete this.device;
    delete this.debug;

    this.domains = new DomainList(this.domains);
    this.http.modules = new ModuleList(this.http.modules);
    this.tls.modules  = new ModuleList(this.tls.modules);
    this.tcp.modules  = new ModuleList(this.tcp.modules);
    this.udp.modules  = new ModuleList(this.udp.modules);
    this.ddns.modules = new ModuleList(this.ddns.modules);
  }

  update(update) {
    var self = this;

    if (update.domains) {
      update.domains.forEach(self.domains.add, self.domains);
    }
    [ 'http', 'tls', 'tcp', 'udp', 'ddns' ].forEach(function (name) {
      if (update[name] && update[name].modules) {
        update[name].modules.forEach(self[name].modules.add, self[name].modules);
        delete update[name].modules;
      }
    });

    function mergeSettings(orig, changes) {
      Object.keys(changes).forEach(function (key) {
        // TODO: use an API that can properly handle updating arrays.
        if (!changes[key] || (typeof changes[key] !== 'object') || Array.isArray(changes[key])) {
          orig[key] = changes[key];
        }
        else if (!orig[key] || typeof orig[key] !== 'object') {
          orig[key] = changes[key];
        }
        else {
          mergeSettings(orig[key], changes[key]);
        }
      });
    }
    mergeSettings(this, update);

    return validate(this);
  }

  validate() {
    return validate(this);
  }
}
module.exports.ConfigChanger = ConfigChanger;