'use strict';

module.exports.create = function (deps, conf) {
  var dns = deps.PromiseA.promisifyAll(require('dns'));
  var network = deps.PromiseA.promisifyAll(deps.recase.camelCopy(require('network')));
  var equal = require('deep-equal');

  var utils = require('./utils').create(deps, conf);
  var loopback = require('./loopback').create(deps, conf, utils);
  var dnsCtrl = require('./dns-ctrl').create(deps, conf, utils);
  var challenge = require('./challenge-responder').create(deps, conf, utils);
  var tunnelClients = require('./tunnel-client-manager').create(deps, conf, utils);

  var loopbackDomain;

  var tunnelActive = false;
  async function startTunnel(tunnelSession, mod, domainList) {
    try {
      var dnsSession = await utils.getSession(mod.tokenId);
      var tunnelDomain = await tunnelClients.start(tunnelSession || dnsSession, domainList);

      var addrList;
      try {
        addrList = await dns.resolve4Async(tunnelDomain);
      } catch (e) {}
      if (!addrList || !addrList.length) {
        try {
          addrList = await dns.resolve6Async(tunnelDomain);
        } catch (e) {}
      }
      if (!addrList || !addrList.length || !addrList[0]) {
        throw new Error('failed to lookup IP for tunnel domain "' + tunnelDomain + '"');
      }

      if (!mod.disabled) {
        await dnsCtrl.setDeviceAddress(dnsSession, addrList[0], domainList);
      }
    } catch (err) {
      console.log('error starting tunnel for', domainList.join(', '));
      console.log(err);
    }
  }
  async function connectAllTunnels() {
    var tunnelSession;
    if (conf.ddns.tunnel) {
      // In the case of a non-existant token, I'm not sure if we want to throw here and prevent
      // any tunnel connections, or if we  want to ignore it and fall back to the DNS tokens
      tunnelSession = await deps.storage.tokens.get(conf.ddns.tunnel.tokenId);
    }

    await utils.iterateAllModules(function (mod, domainList) {
      if (mod.type !== 'dns@oauth3.org') { return null; }

      return startTunnel(tunnelSession, mod, domainList);
    });

    tunnelActive = true;
  }
  async function disconnectTunnels() {
    tunnelClients.disconnect();
    tunnelActive = false;
    await Promise.resolve();
  }
  async function checkTunnelTokens() {
    var oldTokens = tunnelClients.current();

    var newTokens = await utils.iterateAllModules(function checkTokens(mod, domainList) {
      if (mod.type !== 'dns@oauth3.org') { return null; }

      var domainStr = domainList.slice().sort().join(',');
      // If there is already a token handling exactly the domains this modules
      // needs handled remove it from the list of tokens to be removed. Otherwise
      // return the module and domain list so we can get new tokens.
      if (oldTokens[domainStr]) {
        delete oldTokens[domainStr];
      } else {
        return Promise.resolve({ mod, domainList });
      }
    });

    await Promise.all(Object.values(oldTokens).map(tunnelClients.remove));

    if (!newTokens.length) { return; }

    var tunnelSession;
    if (conf.ddns.tunnel) {
      // In the case of a non-existant token, I'm not sure if we want to throw here and prevent
      // any tunnel connections, or if we  want to ignore it and fall back to the DNS tokens
      tunnelSession = await deps.storage.tokens.get(conf.ddns.tunnel.tokenId);
    }

    await Promise.all(newTokens.map(function ({mod, domainList}) {
      return startTunnel(tunnelSession, mod, domainList);
    }));
  }

  var localAddr, gateway;
  async function checkNetworkEnv() {
    // Since we can't detect the OS level events when a user plugs in an ethernet cable to recheck
    // what network environment we are in we check our local network address and the gateway to
    // determine if we need to run the loopback check and router configuration again.
    var addr = await network.getPrivateIpAsync();
    // Until the author of the `network` package publishes the pull request we gave him
    // checking the gateway on our units fails because we have the busybox versions of
    // the linux commands. Gateway is realistically less important than address, so if
    // we fail in getting it go ahead and use the null value.
    var gw;
    try {
      gw = await network.getGatewayIpAsync();
    } catch (err) {
      gw = null;
    }
    if (localAddr === addr && gateway === gw) {
      return;
    }

    var loopResult = await loopback(loopbackDomain);
    var notLooped = Object.keys(loopResult.ports).filter(function (port) {
      return !loopResult.ports[port];
    });

    // if (notLooped.length) {
    //   // TODO: try to automatically configure router to forward ports to us.
    // }

    // If we are on a public address or all ports we are listening on are forwarded to us then
    // we don't need the tunnel and we can set the DNS records for all our domains to our public
    // address. Otherwise we need to use the tunnel to accept traffic. Also since the tunnel will
    // only be listening on ports 80 and 443 if those are forwarded to us we don't want the tunnel.
    if (!notLooped.length || (loopResult.ports['80'] && loopResult.ports['443'])) {
      if (tunnelActive) {
        await disconnectTunnels();
      }
    } else {
      if (!tunnelActive) {
        await connectAllTunnels();
      }
    }

    // Don't assign these until the end of the function. This means that if something failed
    // in the loopback or tunnel connection that we will try to go through the whole process
    // again next time and hopefully the error is temporary (but if not I'm not sure what the
    // correct course of action would be anyway).
    localAddr = addr;
    gateway = gw;
  }

  var publicAddress;
  async function recheckPubAddr() {
    await checkNetworkEnv();
    if (tunnelActive) {
      return;
    }

    var addr = await loopback.checkPublicAddr(loopbackDomain);
    if (publicAddress === addr) {
      return;
    }

    if (conf.debug) {
      console.log('previous public address',publicAddress, 'does not match current public address', addr);
    }
    publicAddress = addr;

    await utils.iterateAllModules(function setModuleDNS(mod, domainList) {
      if (mod.type !== 'dns@oauth3.org' || mod.disabled) { return null; }

      return utils.getSession(mod.tokenId).then(function (session) {
        return dnsCtrl.setDeviceAddress(session, addr, domainList);
      }).catch(function (err) {
        console.log('error setting DNS records for', domainList.join(', '));
        console.log(err);
      });
    });
  }

  function getModuleDiffs(prevConf) {
    var prevMods = {};
    var curMods = {};

    // this returns a Promise, but since the functions we use are synchronous
    // and change our enclosed variables we don't need to wait for the return.
    utils.iterateAllModules(function (mod, domainList) {
      if (mod.type !== 'dns@oauth3.org') { return; }

      prevMods[mod.id] = { mod, domainList };
      return true;
    }, prevConf);
    utils.iterateAllModules(function (mod, domainList) {
      if (mod.type !== 'dns@oauth3.org') { return; }

      curMods[mod.id] = { mod, domainList };
      return true;
    });

    // Filter out all of the modules that are exactly the same including domainList
    // since there is no required action to transition.
    Object.keys(prevMods).map(function (id) {
      if (equal(prevMods[id], curMods[id])) {
        delete prevMods[id];
        delete curMods[id];
      }
    });

    return {prevMods, curMods};
  }
  async function cleanOldDns(prevConf) {
    var {prevMods, curMods} = getModuleDiffs(prevConf);

    // Then remove DNS records for the domains that we are no longer responsible for.
    await Promise.all(Object.values(prevMods).map(function ({mod, domainList}) {
      // If the module was disabled before there should be any records that we need to clean up
      if (mod.disabled) { return; }

      var oldDomains;
      if (!curMods[mod.id] || curMods[mod.id].disabled || mod.tokenId !== curMods[mod.id].mod.tokenId) {
        oldDomains = domainList.slice();
      } else {
        oldDomains = domainList.filter(function (domain) {
          return curMods[mod.id].domainList.indexOf(domain) < 0;
        });
      }
      if (conf.debug) {
        console.log('removing old domains for module', mod.id, oldDomains.join(', '));
      }
      if (!oldDomains.length) {
        return;
      }

      return utils.getSession(mod.tokenId).then(function (session) {
        return dnsCtrl.removeDomains(session, oldDomains);
      });
    }).filter(Boolean));
  }
  async function setNewDns(prevConf) {
    var {prevMods, curMods} = getModuleDiffs(prevConf);

    // And add DNS records for any newly added domains.
    await Promise.all(Object.values(curMods).map(function ({mod, domainList}) {
      // Don't set any new records if the module has been disabled.
      if (mod.disabled) { return; }

      var newDomains;
      if (!prevMods[mod.id] || mod.tokenId !== prevMods[mod.id].mod.tokenId) {
        newDomains = domainList.slice();
      } else {
        newDomains = domainList.filter(function (domain) {
          return prevMods[mod.id].domainList.indexOf(domain) < 0;
        });
      }
      if (conf.debug) {
        console.log('adding new domains for module', mod.id, newDomains.join(', '));
      }
      if (!newDomains.length) {
        return;
      }

      return utils.getSession(mod.tokenId).then(function (session) {
        return dnsCtrl.setDeviceAddress(session, publicAddress, newDomains);
      });
    }).filter(Boolean));
  }

  function check() {
    recheckPubAddr().catch(function (err) {
      console.error('failed to handle all actions needed for DDNS');
      console.error(err);
    });
  }
  check();
  setInterval(check, 5*60*1000);

  var curConf;
  function updateConf() {
    if (curConf && equal(curConf.ddns, conf.ddns) && equal(curConf.domains, conf.domains)) {
      // We could update curConf, but since everything we care about is the same...
      return;
    }

    if (!curConf || !equal(curConf.ddns.loopback, conf.ddns.loopback)) {
      loopbackDomain = 'oauth3.org';
      if (conf.ddns && conf.ddns.loopback) {
        if (conf.ddns.loopback.type === 'tunnel@oauth3.org' && conf.ddns.loopback.domain) {
          loopbackDomain = conf.ddns.loopback.domain;
        } else {
          console.error('invalid loopback configuration: bad type or missing domain');
        }
      }
    }

    if (!curConf) {
      // We need to make a deep copy of the config so we can use it next time to
      // compare and see what setup/cleanup is needed to adapt to the changes.
      curConf = JSON.parse(JSON.stringify(conf));
      return;
    }

    cleanOldDns(curConf).then(function () {
      if (!tunnelActive) {
        return setNewDns(curConf);
      }
      if (equal(curConf.ddns.tunnel, conf.ddns.tunnel)) {
        return checkTunnelTokens();
      } else {
        return disconnectTunnels().then(connectAllTunnels);
      }
    }).catch(function (err) {
      console.error('error transitioning DNS between configurations');
      console.error(err);
    }).then(function () {
      // We need to make a deep copy of the config so we can use it next time to
      // compare and see what setup/cleanup is needed to adapt to the changes.
      curConf = JSON.parse(JSON.stringify(conf));
    });
  }
  updateConf();

  return {
    loopbackServer:     loopback.server
  , setDeviceAddress:   dnsCtrl.setDeviceAddress
  , getDeviceAddresses: dnsCtrl.getDeviceAddresses
  , recheckPubAddr:     recheckPubAddr
  , updateConf:         updateConf
  , challenge
  };
};