/** @file

  Transforms content using gzip, deflate, brotli or zstd

  @section license License

  Licensed to the Apache Software Foundation (ASF) under one
  or more contributor license agreements.  See the NOTICE file
  distributed with this work for additional information
  regarding copyright ownership.  The ASF licenses this file
  to you under the Apache License, Version 2.0 (the
  "License"); you may not use this file except in compliance
  with the License.  You may obtain a copy of the License at

      http://www.apache.org/licenses/LICENSE-2.0

  Unless required by applicable law or agreed to in writing, software
  distributed under the License is distributed on an "AS IS" BASIS,
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  See the License for the specific language governing permissions and
  limitations under the License.
 */

#include "tscore/ink_config.h"
#include "configuration.h"
#include <algorithm>
#include <unordered_map>
#include <vector>
#include <fnmatch.h>
#include <system_error>
#include <stdexcept>

#include "swoc/swoc_file.h"
#include "swoc/TextView.h"
#include "swoc/Lexicon.h"

#include "debug_macros.h"

#include <cctype>

namespace Compress
{
// isspace and friends invoke undefined behavior when the argument is a
// signed char with the high bit set (negative when sign-extended to int).
// HTTP headers can carry obs-text bytes (>= 0x80), so route every ctype
// call through this wrapper that casts to unsigned char first.
inline int
safe_isspace(int ch)
{
  return ::isspace(static_cast<unsigned char>(ch));
}

swoc::TextView
extractFirstToken(swoc::TextView &view, int (*fp)(int))
{
  // Skip leading delimiters
  view.ltrim_if(fp);

  // Extract token up to (and removing) the first delimiter
  auto token = view.take_prefix_if(fp);

  return token;
}

enum class ParserState {
  Start,
  CompressibleContentType,
  RemoveAcceptEncoding,
  Enable,
  Cache,
  RangeRequest,
  Flush,
  Allow,
  MinimumContentLength,
  ContentTypeIgnoreParameters,
  GzipCompressionLevel,
  BrotliCompressionLevel,
  BrotliLGWSize,
  ZstdCompressionLevel
};

void
Configuration::add_host_configuration(HostConfiguration *hc)
{
  host_configurations_.emplace_back(hc);
}

void
HostConfiguration::update_defaults()
{
  // maintain backwards compatibility/usability out of the box
  if (compressible_status_codes_.empty()) {
    compressible_status_codes_ = {TS_HTTP_STATUS_OK, TS_HTTP_STATUS_PARTIAL_CONTENT, TS_HTTP_STATUS_NOT_MODIFIED};
  }
}

void
HostConfiguration::add_allow(swoc::TextView allow)
{
  allows_.emplace_back(allow);
}

void
HostConfiguration::add_compressible_content_type(swoc::TextView content_type)
{
  compressible_content_types_.emplace_back(content_type);
}

HostConfiguration *
Configuration::find(const char *host, int host_length)
{
  HostConfiguration *host_configuration = host_configurations_[0];

  if (host && host_length > 0 && host_configurations_.size() > 1) {
    swoc::TextView host_view(host, host_length);

    // Start from index 1 to skip the default configuration at index 0
    for (size_t i = 1; i < host_configurations_.size(); ++i) {
      if (host_configurations_[i]->host() == host_view) {
        host_configuration = host_configurations_[i];
        break;
      }
    }
  }

  return host_configuration;
}

bool
HostConfiguration::is_url_allowed(const char *url, int url_len)
{
  swoc::TextView url_view(url, url_len);

  if (has_allows()) {
    // fnmatch requires null-terminated strings, so we need a std::string for the url
    std::string surl(url_view);
    for (const auto &allow : allows_) {
      const char *match_string = allow.c_str();
      bool        exclude      = allow.starts_with('!');
      if (exclude) {
        ++match_string; // skip !
      }
      if (fnmatch(match_string, surl.c_str(), 0) == 0) {
        info("url [%.*s] %s for compression, matched allow pattern [%s]", static_cast<int>(url_view.size()), url_view.data(),
             exclude ? "disabled" : "enabled", allow.c_str());
        return !exclude;
      }
    }
    info("url [%.*s] disabled for compression, did not match any allows pattern", static_cast<int>(url_view.size()),
         url_view.data());
    return false;
  }
  info("url [%.*s] enabled for compression, did not match any pattern", static_cast<int>(url_view.size()), url_view.data());
  return true;
}

bool
HostConfiguration::is_status_code_compressible(const TSHttpStatus status_code) const
{
  return compressible_status_codes_.contains(status_code);
}

swoc::TextView
strip_params(swoc::TextView v)
{
  v = v.take_prefix_at(';');
  v.rtrim_if(&safe_isspace);
  return v;
}

bool
HostConfiguration::is_content_type_compressible(const char *content_type, int content_type_length)
{
  swoc::TextView content_type_view(content_type, content_type_length);
  bool           is_match = false;

  for (const auto &content_type_pattern : compressible_content_types_) {
    const char *match_string = content_type_pattern.c_str();
    bool        exclude      = content_type_pattern.starts_with('!');

    if (exclude) {
      ++match_string; // skip '!'
    }
    std::string target;

    if (content_type_ignore_parameters() && content_type_pattern.find(';') == std::string::npos) {
      target = strip_params(content_type_view);
    } else {
      target = content_type_view;
    }
    if (fnmatch(match_string, target.c_str(), 0) == 0) {
      info("compressible content type [%s], matched on pattern [%s]", target.c_str(), content_type_pattern.c_str());
      is_match = !exclude;
    }
  }

  return is_match;
}

constexpr int
isCommaOrSpace(int ch)
{
  return (ch == ',') or safe_isspace(ch);
}

void
HostConfiguration::add_compression_algorithms(swoc::TextView &line)
{
  compression_algorithms_ = ALGORITHM_DEFAULT; // remove the default gzip.
  for (;;) {
    auto token = extractFirstToken(line, isCommaOrSpace);
    if (token.empty()) {
      break;
    } else if (token == "zstd") {
#ifdef HAVE_ZSTD_H
      compression_algorithms_ |= ALGORITHM_ZSTD;
#else
      error("supported-algorithms: zstd support not compiled in.");
#endif
    } else if (token == "br") {
#ifdef HAVE_BROTLI_ENCODE_H
      compression_algorithms_ |= ALGORITHM_BROTLI;
#else
      error("supported-algorithms: brotli support not compiled in.");
#endif
    } else if (token == "gzip") {
      compression_algorithms_ |= ALGORITHM_GZIP;
    } else if (token == "deflate") {
      compression_algorithms_ |= ALGORITHM_DEFLATE;
    } else {
#ifdef HAVE_ZSTD_H
      error("Unknown compression type. Supported compression-algorithms <zstd,br,gzip,deflate>.");
#else
      error("Unknown compression type. Supported compression-algorithms <br,gzip,deflate>.");
#endif
    }
  }
}

void
HostConfiguration::add_compressible_status_codes(swoc::TextView &line)
{
  compressible_status_codes_.clear();

  for (;;) {
    auto token = extractFirstToken(line, isCommaOrSpace);
    if (token.empty()) {
      break;
    }

    swoc::TextView parsed;
    uintmax_t      status_code = swoc::svtou(token, &parsed);
    if (parsed.size() == token.size() && status_code > 0) {
      compressible_status_codes_.insert(static_cast<TSHttpStatus>(status_code));
    } else {
      error("Invalid status code %.*s", static_cast<int>(token.size()), token.data());
    }
  }
}

int
HostConfiguration::compression_algorithms()
{
  return compression_algorithms_;
}

/**
  "true" and "false" are compatibility with old version, will be removed
 */

// Lexicon for mapping range-request configuration tokens to enum values
static const swoc::Lexicon<RangeRequestCtrl> RangeRequestLexicon{
  swoc::Lexicon<RangeRequestCtrl>::with_multi{{RangeRequestCtrl::NONE, {"true", "none"}},
                                              {RangeRequestCtrl::NO_COMPRESSION, {"false", "no-compression"}},
                                              {RangeRequestCtrl::REMOVE_RANGE, {"remove-range"}},
                                              {RangeRequestCtrl::REMOVE_ACCEPT_ENCODING, {"remove-accept-encoding"}}}
};

static const std::unordered_map<std::string_view, ParserState> KeywordToStateMap{
  {"compressible-content-type",      ParserState::CompressibleContentType    },
  {"content_type_ignore_parameters", ParserState::ContentTypeIgnoreParameters},
  {"remove-accept-encoding",         ParserState::RemoveAcceptEncoding       },
  {"enabled",                        ParserState::Enable                     },
  {"cache",                          ParserState::Cache                      },
  {"range-request",                  ParserState::RangeRequest               },
  {"flush",                          ParserState::Flush                      },
  {"allow",                          ParserState::Allow                      },
  {"minimum-content-length",         ParserState::MinimumContentLength       },
  {"gzip-compression-level",         ParserState::GzipCompressionLevel       },
  {"brotli-compression-level",       ParserState::BrotliCompressionLevel     },
  {"brotli-lgwin",                   ParserState::BrotliLGWSize              },
  {"zstd-compression-level",         ParserState::ZstdCompressionLevel       }
};

void
HostConfiguration::set_range_request(swoc::TextView token)
{
  try {
    range_request_ctl_ = RangeRequestLexicon[token];
  } catch (std::domain_error const &) {
    error("invalid token for range_request: %.*s", static_cast<int>(token.size()), token.data());
  }
}

Configuration *
Configuration::Parse(const char *path)
{
  swoc::file::path pathstring(path);

  // If we have a path and it's not an absolute path, make it relative to the
  // configuration directory.
  if (!pathstring.is_absolute()) {
    pathstring = swoc::file::path(TSConfigDirGet()) / pathstring;
  }

  Configuration     *c                          = new Configuration();
  HostConfiguration *current_host_configuration = new HostConfiguration("");

  c->add_host_configuration(current_host_configuration);

  if (pathstring.empty()) {
    return c;
  }

  auto path_string = pathstring.c_str();
  info("Parsing file \"%s\"", path_string);

  std::error_code ec;
  std::string     content = swoc::file::load(pathstring, ec);

  if (ec) {
    warning("could not open file [%s], skip: %s", path_string, ec.message().c_str());
    return c;
  }

  ParserState state  = ParserState::Start;
  size_t      lineno = 0;

  swoc::TextView content_view(content);
  while (content_view) {
    auto line_view = content_view.take_prefix_at('\n');
    ++lineno;

    // Trim whitespace
    line_view.trim_if(&safe_isspace);
    if (line_view.empty()) {
      continue;
    }
    for (;;) {
      auto token = extractFirstToken(line_view, safe_isspace);

      if (token.empty()) {
        break;
      }

      // once a comment is encountered, we are done processing the line
      if (token.starts_with('#')) {
        break;
      }

      switch (state) {
      case ParserState::Start:
        if (token.starts_with('[') && token.ends_with(']')) {
          auto host_name = token.substr(1, token.size() - 2);

          // Makes sure that any default settings are properly set, when not explicitly set via configs
          current_host_configuration->update_defaults();
          current_host_configuration = new HostConfiguration(host_name);
          c->add_host_configuration(current_host_configuration);
        } else if (token == "supported-algorithms") {
          current_host_configuration->add_compression_algorithms(line_view);
          state = ParserState::Start;
        } else if (token == "compressible-status-code") {
          current_host_configuration->add_compressible_status_codes(line_view);
          state = ParserState::Start;
        } else if (auto it = KeywordToStateMap.find(std::string_view(token.data(), token.size())); it != KeywordToStateMap.end()) {
          state = it->second;
        } else {
          warning("failed to interpret \"%.*s\" at line %zu", static_cast<int>(token.size()), token.data(), lineno);
        }
        break;
      case ParserState::CompressibleContentType:
        current_host_configuration->add_compressible_content_type(token);
        state = ParserState::Start;
        break;
      case ParserState::ContentTypeIgnoreParameters:
        current_host_configuration->set_content_type_ignore_parameters(token == "true");
        state = ParserState::Start;
        break;
      case ParserState::RemoveAcceptEncoding:
        current_host_configuration->set_remove_accept_encoding(token == "true");
        state = ParserState::Start;
        break;
      case ParserState::Enable:
        current_host_configuration->set_enabled(token == "true");
        state = ParserState::Start;
        break;
      case ParserState::Cache:
        current_host_configuration->set_cache(token == "true");
        state = ParserState::Start;
        break;
      case ParserState::RangeRequest:
        current_host_configuration->set_range_request(token);
        state = ParserState::Start;
        break;
      case ParserState::Flush:
        current_host_configuration->set_flush(token == "true");
        state = ParserState::Start;
        break;
      case ParserState::Allow:
        current_host_configuration->add_allow(token);
        state = ParserState::Start;
        break;
      case ParserState::MinimumContentLength: {
        swoc::TextView parsed;
        uintmax_t      length = swoc::svtou(token, &parsed);
        if (parsed.size() == token.size()) {
          current_host_configuration->set_minimum_content_length(length);
        }
        state = ParserState::Start;
        break;
      }
      case ParserState::GzipCompressionLevel: {
        swoc::TextView parsed;
        intmax_t       level = swoc::svtoi(token, &parsed);
        if (parsed.size() == token.size() && level >= 1 && level <= 9) {
          current_host_configuration->set_gzip_compression_level(level);
        } else {
          error("gzip-compression-level must be between 1 and 9, got %.*s", static_cast<int>(token.size()), token.data());
        }
        state = ParserState::Start;
        break;
      }
      case ParserState::BrotliCompressionLevel: {
        swoc::TextView parsed;
        intmax_t       level = swoc::svtoi(token, &parsed);
        if (parsed.size() == token.size() && level >= 0 && level <= 11) {
          current_host_configuration->set_brotli_compression_level(level);
        } else {
          error("brotli-compression-level must be between 0 and 11, got %.*s", static_cast<int>(token.size()), token.data());
        }
        state = ParserState::Start;
        break;
      }
      case ParserState::BrotliLGWSize: {
        swoc::TextView parsed;
        intmax_t       lgw = swoc::svtoi(token, &parsed);
        if (parsed.size() == token.size() && lgw >= 10 && lgw <= 24) {
          current_host_configuration->set_brotli_lgw_size(lgw);
        } else {
          error("brotli-lgwin must be between 10 and 24, got %.*s", static_cast<int>(token.size()), token.data());
        }
        state = ParserState::Start;
        break;
      }
      case ParserState::ZstdCompressionLevel: {
        swoc::TextView parsed;
        intmax_t       level = swoc::svtoi(token, &parsed);
        if (parsed.size() == token.size() && level >= 1 && level <= 22) {
          current_host_configuration->set_zstd_compression_level(level);
        } else {
          error("zstd-compression-level must be between 1 and 22, got %.*s", static_cast<int>(token.size()), token.data());
        }
        state = ParserState::Start;
        break;
      }
      }
    }
  }

  // Update the defaults for the last host configuration too, if needed.
  current_host_configuration->update_defaults();

  // Check combination of configs
  if (!current_host_configuration->cache() && current_host_configuration->range_request_ctl() == RangeRequestCtrl::NONE) {
    warning("Combination of 'cache false' and 'range-request none' might deliver corrupted content");
  }

  if (state != ParserState::Start) {
    warning("the parser state indicates that data was expected when it reached the end of the file (%d)", static_cast<int>(state));
  }

  return c;
} // Configuration::Parse
} // namespace Compress
