// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "net/tools/tld_cleanup/tld_cleanup_util.h"

#include <algorithm>
#include <sstream>
#include <string>
#include <tuple>
#include <utility>

#include "base/files/file_util.h"
#include "base/logging.h"
#include "base/strings/strcat.h"
#include "base/strings/string_number_conversions.h"
#include "net/base/registry_controlled_domain_constants.h"
#include "url/gurl.h"
#include "url/third_party/mozilla/url_parse.h"

namespace net::tld_cleanup {

namespace {

constexpr char kBeginPrivateDomainsComment[] = "// ===BEGIN PRIVATE DOMAINS===";
constexpr char kEndPrivateDomainsComment[] = "// ===END PRIVATE DOMAINS===";
constexpr char kGperfPreamble[] =
    "%{\n"
    "// Copyright 2012 The Chromium Authors\n"
    "// Use of this source code is governed by a BSD-style license "
    "that can be\n"
    "// found in the LICENSE file.\n\n"
    "// This file is generated by net/tools/tld_cleanup/.\n"
    "// DO NOT MANUALLY EDIT!\n"
    "%}\n"
    "%%\n";

}  // namespace

int Rule::Serialize() const {
  DomainRuleTags type;
  if (exception) {
    type = {DomainRuleTag::kException};
  } else if (wildcard) {
    type = {DomainRuleTag::kWildcard};
  }
  if (is_private) {
    type.Put(DomainRuleTag::kPrivate);
  }
  return type.ToEnumBitmask();
}

std::string RulesToGperf(const RuleMap& rules) {
  std::string data(kGperfPreamble);

  for (const auto& [domain, rule] : rules) {
    base::StrAppend(
        &data, {domain, ", ", base::NumberToString(rule.Serialize()), "\n"});
  }

  data.append("%%\n");

  return data;
}

// Adjusts the rule to a standard form: removes single extraneous dots and
// canonicalizes it using GURL. Returns kSuccess if the rule is interpreted as
// valid; logs a warning and returns kWarning if it is probably invalid; and
// logs an error and returns kError if the rule is (almost) certainly invalid.
std::tuple<NormalizeResult, std::string, Rule> NormalizeRule(std::string domain,
                                                             Rule rule) {
  NormalizeResult result = NormalizeResult::kSuccess;

  // Strip single leading and trailing dots.
  if (domain.starts_with(".")) {
    domain.erase(0, 1);
  }
  if (domain.ends_with(".")) {
    domain.pop_back();
  }

  // Allow single leading '*.' or '!', saved here so it's not canonicalized.
  if (domain.starts_with("!")) {
    domain.erase(0, 1);
    rule.exception = true;
  } else if (domain.starts_with("*.")) {
    domain.erase(0, 2);
    rule.wildcard = true;
  }
  if (domain.empty()) {
    LOG(WARNING) << "Ignoring empty rule";
    return std::make_tuple(NormalizeResult::kWarning, domain, rule);
  }

  // Warn about additional '*.' or '!'.
  if (domain.contains("*.") || domain.contains('!')) {
    LOG(WARNING) << "Keeping probably invalid rule: " << domain;
    result = NormalizeResult::kWarning;
  }

  // Make a GURL and normalize it, then get the host back out.
  GURL gurl(base::StrCat({"http://", domain}));
  const std::string& spec = gurl.possibly_invalid_spec();
  url::Component host = gurl.parsed_for_possibly_invalid_spec().host;
  if (!host.is_valid()) {
    LOG(ERROR) << "Ignoring rule that couldn't be normalized: " << domain;
    return std::make_tuple(NormalizeResult::kError, domain, rule);
  }
  if (!gurl.is_valid()) {
    LOG(WARNING) << "Keeping rule that GURL says is invalid: " << domain;
    result = NormalizeResult::kWarning;
  }
  domain.assign(spec.substr(host.begin, host.len));

  return std::make_tuple(result, domain, rule);
}

std::pair<NormalizeResult, RuleMap> NormalizeDataToRuleMap(
    const std::string& data) {
  RuleMap rules;
  // We do a lot of string assignment during parsing, but simplicity is more
  // important than performance here.
  NormalizeResult result = NormalizeResult::kSuccess;
  std::istringstream data_stream(data);

  bool in_private_section = false;
  RuleMap extra_rules;

  for (std::string line; std::getline(data_stream, line, '\n');) {
    if (line.starts_with(kBeginPrivateDomainsComment)) {
      in_private_section = true;
      continue;
    }
    if (line.starts_with(kEndPrivateDomainsComment)) {
      in_private_section = false;
      continue;
    }
    if (line.starts_with("//")) {
      // Skip comments.
      continue;
    }
    if (line.empty()) {
      continue;
    }

    // Truncate at first whitespace.
    if (size_t first_whitespace = line.find_first_of("\r\n \t");
        first_whitespace != std::string::npos) {
      line.erase(first_whitespace);
    }

    const auto [new_result, domain, rule] =
        NormalizeRule(line, Rule{/*exception=*/false, /*wildcard=*/false,
                                 /*is_private=*/in_private_section});
    result = std::max(result, new_result);
    if (new_result == NormalizeResult::kError) {
      continue;
    }

    // Check the existing rules to make sure we don't have an exception and
    // wildcard for the same rule, or that the same domain is listed as both
    // private and not private. If we did, we'd have to update our
    // parsing code to handle this case.
    CHECK(!rules.contains(domain)) << "Duplicate rule found for " << domain;

    rules[domain] = rule;
    // Add true TLD for multi-level rules.  We don't add them right now, in
    // case there's an exception or wild card that either exists or might be
    // added in a later iteration.  In those cases, there's no need to add
    // it and it would just slow down parsing the data.
    size_t tld_start = domain.find_last_of('.');
    if (tld_start != std::string::npos && tld_start + 1 < domain.size()) {
      std::string extra_rule_domain = domain.substr(tld_start + 1);
      RuleMap::const_iterator iter = extra_rules.find(extra_rule_domain);
      // If a rule already exists, we ensure that if any of the entries is not
      // private the result should be that the entry is not private.  An example
      // is .au which is not listed as a real TLD, but only lists second-level
      // domains such as com.au. Subdomains of .au (eg. blogspot.com.au) are
      // also listed in the private section, which is processed later, so this
      // ensures that the real TLD (eg. .au) is listed as public.
      bool is_private = in_private_section &&
                        (iter == extra_rules.end() || iter->second.is_private);
      extra_rules[extra_rule_domain] =
          Rule{/*exception=*/false, /*wildcard=*/false, is_private};
    }
  }

  std::ranges::copy_if(extra_rules, std::inserter(rules, rules.end()),
                       [&](const auto& extra_rule) {
                         return !rules.contains(extra_rule.first);
                       });

  return std::make_pair(result, rules);
}

NormalizeResult NormalizeFile(const base::FilePath& in_filename,
                              const base::FilePath& out_filename) {
  std::string data;
  if (!base::ReadFileToString(in_filename, &data)) {
    LOG(ERROR) << "Unable to read file";
    return NormalizeResult::kError;
  }

  const auto [result, rules] = NormalizeDataToRuleMap(data);

  if (!base::WriteFile(out_filename, RulesToGperf(rules))) {
    LOG(ERROR) << "Error(s) writing output file";
    return NormalizeResult::kError;
  }

  return result;
}

}  // namespace net::tld_cleanup
