/*
 * Copyright (C) 2022 StApps
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
import {Logger} from '@openstapps/logger';
import Dockerode from 'dockerode';
import isCidr from 'is-cidr';
import mustache from 'mustache';
import path from 'path';
import * as semver from 'semver';
import {
  configFile,
  isFileType,
  protocolHardeningParameters,
  SSLFilePaths,
  sslHardeningParameters,
  SupportedLogFormats,
  TemplateView,
} from './common.js';
import {readFile} from 'fs/promises';
import PortScanner from './port-scanner.js';

/* eslint-disable unicorn/no-await-expression-member */

/**
 * Checks if a ContainerInfo matches a name and version regex
 * @param name Name to check
 * @param versionRegex Version regex to check
 * @param container Container info for check
 */
export function containerMatchesRegex(
  name: string,
  versionRegex: RegExp,
  container: Dockerode.ContainerInfo,
): boolean {
  return (
    typeof container.Labels['stapps.version'] === 'string' &&
    container.Labels['stapps.version'].match(versionRegex) !== null &&
    ((typeof container.Labels['com.docker.compose.service'] === 'string' &&
      container.Labels['com.docker.compose.service'] === name) ||
      (typeof container.Labels['com.docker.stack.namespace'] === 'string' &&
        container.Labels['com.docker.swarm.service.name'] ===
          `${container.Labels['com.docker.stack.namespace']}_${name}`))
  );
}

/**
 * Gets Gateway (ip:port) of given ContainerInfo
 *
 * Returns an empty String if there is no Gateway.
 * This assumes that a backend runs in the container and it exposes one port.
 * @param container Container info of which to get address from
 */
export async function getGatewayOfStAppsBackend(container: Dockerode.ContainerInfo): Promise<string> {
  if (container.Ports.length === 0) {
    await Logger.error(`Container ${container.Names[0]} does not advertise any port.
Please expose a port if the container should be accessible by NGINX.`);

    return '';
  }

  // Basic Docker network
  if (container.Ports[0].IP !== undefined && container.Ports[0].PublicPort !== undefined) {
    // ip:port
    return `${container.Ports[0].IP}:${container.Ports[0].PublicPort}`;
  }

  // Docker Swarm network
  if (
    /* istanbul ignore next */
    container.NetworkSettings?.Networks?.ingress?.IPAddress !== undefined &&
    container.Ports[0].PrivatePort !== undefined
  ) {
    const port = container.Ports[0].PrivatePort;

    // Get a routable network connection
    for (const network in container.NetworkSettings.Networks) {
      if (await PortScanner.isPortFree(port, container.NetworkSettings.Networks[network].IPAddress)) {
        Logger.info(
          `${container.Names[0]} reachable via ${container.NetworkSettings.Networks[network].IPAddress}:${port}`,
        );
        return `${container.NetworkSettings.Networks[network].IPAddress}:${port}`;
      }
    }
  }

  await Logger.error(
    `Couldn't infer ${container.Names[0]} network reachability. It's possible your current Docker network setup isn't supported yet.`,
  );

  return '';
}

/**
 * Generates an upstream map
 *
 * It maps all stapps-backend-containers to an gateway.
 * @param activeVersions List of active versions
 * @param outdatedVersions List of outdated versions
 * @param containers List of container infos
 */
export async function generateUpstreamMap(
  activeVersions: string[],
  outdatedVersions: string[],
  containers: Dockerode.ContainerInfo[],
): Promise<string> {
  let result = 'map $http_x_stapps_version $proxyurl {\n  default unsupported;\n';
  let upstreams = '';

  let foundMatchingContainer = false;

  // const backendContainer = containers.filter(container => container.Image.includes('backend'));
  // // eslint-disable-next-line no-console
  //console.log(JSON.stringify(backendContainer, undefined, 2));

  // active versions
  result += (
    await Promise.all(
      activeVersions.map(async activeVersionRegex => {
        const upstreamName = activeVersionRegex.replaceAll(/[\\|.+]/g, '_');

        let activeBackends = containers.filter(container => {
          return containerMatchesRegex('backend', new RegExp(activeVersionRegex), container);
        });

        // .Labels['stapps.version'] is available
        if (activeBackends.length > 0) {
          activeBackends = activeBackends.sort((a, b) =>
            semver.rcompare(a.Labels['stapps.version'], b.Labels['stapps.version']),
          );
          const activeBackendsVersions = activeBackends
            .map(container => container.Labels['stapps.version'])
            // eslint-disable-next-line unicorn/no-array-reduce
            .reduce(
              (map, element) => map.set(element, (map.get(element) || 0) + 1),
              new Map<string, number>(),
            );
          for (const [version, occurrences] of activeBackendsVersions) {
            if (occurrences > 1) {
              await Logger.error(
                `Omitting running version ${version} ! Multiple backends with this exact version are running.`,
              );
              activeBackends = activeBackends.filter(
                container => container.Labels['stapps.version'] !== version,
              );
            }
          }

          if (activeBackends.length > 0) {
            // not only duplicates
            foundMatchingContainer = true;

            const gatewayOfContainer = await getGatewayOfStAppsBackend(activeBackends[0]);

            if (gatewayOfContainer.length > 0) {
              upstreams += `\nupstream ${upstreamName} {\n  server ${gatewayOfContainer};\n}`;

              return `  \"~${activeVersionRegex}\" ${upstreamName};\n`;
            }
          }
        }
        Logger.warn('No backend for version', activeVersionRegex, 'found');

        return `  \"~${activeVersionRegex}\" unavailable;\n`;
      }),
    )
  ).join('');

  // outdated versions
  result += outdatedVersions
    .map(outdatedVersionRegex => {
      return `  \"~${outdatedVersionRegex}\" outdated;`;
    })
    .join('\n');

  result += '\n}';

  if (!foundMatchingContainer) {
    await Logger.error(
      'No container with matching version label found. Please start a container with a matching version Label.',
    );
  }

  return `${result}${upstreams}\n`;
}

/**
 * Generates http or https listener
 */
export function generateListener(sslFilePaths: SSLFilePaths) {
  let listener = '';

  if (
    sslFilePaths !== undefined &&
    sslFilePaths.certificate !== undefined &&
    isFileType(sslFilePaths.certificate, 'crt') &&
    sslFilePaths.certificateChain !== undefined &&
    isFileType(sslFilePaths.certificateChain, 'crt') &&
    sslFilePaths.certificateKey !== undefined &&
    isFileType(sslFilePaths.certificateKey, 'key') &&
    sslFilePaths.dhparam !== undefined &&
    isFileType(sslFilePaths.dhparam, 'pem')
  ) {
    // https listener
    listener = `    listen 443 ssl default_server;
    ssl_certificate ${sslFilePaths.certificate};
    ssl_certificate_key ${sslFilePaths.certificateKey};
    ssl_trusted_certificate ${sslFilePaths.certificateChain};
    ssl_dhparam ${sslFilePaths.dhparam};
    ${sslHardeningParameters}`;
  } else {
    // default http listener
    listener = 'listen 80 default_server;';

    Logger.warn('Https usage is not setup properly, falling back to http!');
  }
  listener = `${listener}

${protocolHardeningParameters}
`;

  return listener;
}

/**
 * Reads predefined server entry with metrics location
 */
export async function generateMetricsServer(logFormat: string, enableMetrics?: boolean): Promise<string> {
  if (!enableMetrics) {
    return '';
  }

  return renderTemplate(path.join('fixtures', 'metrics.template'), {
    logFormat: logFormat,
  });
}

/**
 * Render a mustache template file with given view object
 * @param path Path to template file
 * @param view Data to render template with
 */
async function renderTemplate(path: string, view: unknown): Promise<string> {
  const content = await readFile(path, 'utf8');

  return mustache.render(content, view);
}

/**
 * Generate allow list entries in CIDR notation that pass thru rate limiting
 * @param entries Allow list entries that should be in CIDR notation
 */
function generateRateLimitAllowList(entries: string[]): string {
  return entries
    .filter(entry => isCidr(entry))
    .map(entry => `${entry} 0;`)
    .join('\n');
}

/**
 * Returns view for nginx config file
 * @param containers List of container info
 */
export async function getTemplateView(containers: Dockerode.ContainerInfo[]): Promise<TemplateView> {
  const cors = await readFile('./fixtures/cors.template', 'utf8');

  const visibleRoutesPromises = ['/'].map(async route => {
    return renderTemplate(path.join('fixtures', 'visibleRoute.template'), {
      cors,
      route,
    });
  });

  const hiddenRoutesPromises = configFile.hiddenRoutes.map(async route => {
    return renderTemplate(path.join('fixtures', 'hiddenRoute.template'), {
      cors,
      route,
    });
  });

  const logFormattingPromise = renderTemplate(path.join('fixtures', 'logFormatters.template'), {});

  const logFormat =
    configFile.logFormat in SupportedLogFormats
      ? SupportedLogFormats[configFile.logFormat]
      : SupportedLogFormats.default;

  return {
    dockerVersionMap: await generateUpstreamMap(
      configFile.activeVersions,
      configFile.outdatedVersions,
      containers,
    ),
    hiddenRoutes: (await Promise.all(hiddenRoutesPromises)).join(''),
    listener: generateListener(configFile.sslFilePaths),
    logFormat: logFormat,
    logFormatters: await logFormattingPromise,
    metrics: await generateMetricsServer(logFormat, configFile.metrics),
    rateLimitAllowList: generateRateLimitAllowList(configFile.rateLimitAllowList),
    staticRoute: await renderTemplate(path.join('fixtures', 'staticRoute.template'), {cors}),
    visibleRoutes: (await Promise.all(visibleRoutesPromises)).join(''),
  };
}

/**
 * Read the list of docker containers
 * @param pathToDockerSocket Path to docker socket
 */
export async function getContainers(
  pathToDockerSocket = '/var/run/docker.sock',
): Promise<Dockerode.ContainerInfo[]> {
  const docker = new Dockerode({
    socketPath: pathToDockerSocket,
  });

  const containers = await docker.listContainers();

  /* istanbul ignore next */
  if (containers.length === 0) {
    throw new Error(
      `No running docker containers found.

Please check if docker is running and Node.js can access the docker socket (${pathToDockerSocket})`,
    );
  }

  /* istanbul ignore next */
  return containers;
}
