# frozen_string_literal: true

class ExternalVaultsCheck # rubocop:disable Metrics/ClassLength
  module Conversions
    TLS_KEYS = %i[ssl_verify client_options].freeze

    private

    def conversion_levels
      @conversion_levels ||= levels.reject(&:template).select do |level|
        bundled?(level.connection[:address]) ? own_service_credentials?(level.own) : converted_external_paths.include?(level.path)
      end
    end

    def subtree_vaults(level)
      nested = conversion_levels.map(&:path).select { it.start_with?("#{level.path}/") }
      subtree_nodes(level.path).reject { |node| nested.any? { within?(node.path, it) } }.map { effective_vault(it) }.group_by(&:first).transform_values { mounts(it) }
    end

    def effective_vault(node)
      vaults = node.path_nodes_settings.map { vault_of(it[:settings_version].data) }
      [vault_key(merged_connection(vaults)), vaults.filter_map { it[:mount_path].presence }.last || global_mount_path]
    end

    def conversion(level, vault, mounts)
      return "#{level.path}: a managed credential for the bundled vault, reaching #{mounts.join(', ')}" if vault == :bundled

      source = vault == identity(level.connection) ? "the credential #{level.path} sets" : 'the credential that vault\'s settings hold, which will not follow a later change to it'
      "#{level.path}: a credential for the external vault at #{vault.compact.join(' namespace ')}, copying #{source}"
    end

    def converted_external_paths = @converted_external_paths ||= credential_groups.reject { tls_differs?(it) || it.any?(&:template) }.flatten.map(&:path)
    def covered_by_conversion?(path) = conversion_levels.any? { within?(path, it.path) }
    def credential_groups = external_levels.group_by { identity(it.connection) }.values.select { |group| group.map { credentials(it.connection) }.uniq.size > 1 }
    def global_mount_path = @global_mount_path ||= SecretVaultConfig.global.mount_path
    def mounts(vaults) = vaults.map(&:last).uniq.sort
    def subtree_nodes(path) = Node.where(path:).or(Node.where('path LIKE ?', "#{Node.sanitize_sql_like(path)}/%")).order(:path).to_a
    def tls_differs?(group) = group.map { |level| TLS_KEYS.map { it == :client_options ? client_options(level.connection) : level.connection[it].to_s } }.uniq.size > 1
    def vault_key(connection) = bundled?(connection[:address]) ? :bundled : identity(connection)
    def within?(path, root) = path == root || path.start_with?("#{root}/")
  end

  include Conversions

  BUNDLED_ADDRESSES = %w[
    https://opschain-secret-vault:8200
    https://opschain-secret-vault-active:8200
    https://opschain-secret-vault-0.opschain-secret-vault-internal:8200
  ].freeze
  CONNECTION_KEYS = %i[address auth_method client_options ssl_verify token username password recovery_key runner_username runner_password].freeze
  CREDENTIAL_KEYS = %i[auth_method token username password ssl_verify client_options].freeze
  GLOBAL_PATH = '/system_configuration'
  OWNER_TYPES = %w[Node Template TemplateVersion].freeze
  RESERVED_CLIENT_OPTION = /\A(address|proxy_.*|token|username|password|auth_method)\z/
  SECRET_REFERENCE_PREFIX = 'secret-vault://'
  SERVICE_CREDENTIAL_KEYS = %i[token username password].freeze

  FINDINGS = {
    global_external: 'The global secret vault is not the bundled vault. Move it as the upgrade notes describe',
    several_credentials: 'These levels reach the same external secret vault with different credentials, and a template level or different TLS options stops them becoming runner credentials. Make them use one set',
    bundled_credentials: 'These templates use the bundled secret vault with their own token, username or password. Remove the override',
    template_without_address: 'These templates set secret vault connection settings without an address. Set the address too, or remove them',
    template_inherited_connection: 'An environment adds secret vault connection settings to these templates for its assets, so those assets would change vault or credential. Set the settings on the template too, or remove them from the environment',
    unmatched_change: 'These unfinished changes use a secret vault connection no settings level has. Let them finish first',
    secret_reference: 'These levels set a secret vault setting to a secret-vault:// reference. Replace it with the value'
  }.freeze

  Level = Struct.new(:path, :own, :connection, :template, :owner)

  CONVERSIONS_HEADING = 'The upgrade will create these runner credentials, each tagged on the level named. Mount paths cover each asset\'s active template version only'

  def self.run(out: $stdout)
    check = new
    findings = check.findings
    out.puts(findings.empty? ? 'No external secret vault conflicts found' : findings.map { |key, paths| "#{FINDINGS.fetch(key)}:\n#{paths.map { "  #{it}" }.join("\n")}" })
    out.puts("#{CONVERSIONS_HEADING}:\n#{check.conversions.map { "  #{it}" }.join("\n")}") if check.conversions.any?
    findings.empty?
  end

  def findings
    ActiveRecord::Base.transaction(requires_new: true) do
      ActiveRecord::Base.connection.execute('SET TRANSACTION READ ONLY')
      @findings = collect_findings
      @conversions = conversion_levels.flat_map { |level| subtree_vaults(level).map { |vault, mounts| conversion(level, vault, mounts) } }
      raise ActiveRecord::Rollback
    end
    @findings
  end

  def conversions
    findings unless defined?(@conversions)
    @conversions
  end

  def self.main(file) = (exit(run ? 0 : 1) if [$PROGRAM_NAME, 'stdin'].include?(file))

  private

  def matches_level?(connection)
    return SERVICE_CREDENTIAL_KEYS.all? { connection[it].to_s == global[it].to_s } if bundled?(connection[:address])

    external_levels.any? { same_connection?(it.connection, connection) }
  end

  def template_inherited_connection
    template_levels = levels.select { it.template && connection_keys?(it.own) && !bundled?(it.connection[:address]) }.index_by(&:owner)
    template_assets(template_levels.keys).find_each.filter_map { inherited_connection_level(it, template_levels) }
  end

  def inherited_connection_level(node, template_levels)
    chain = owner_vaults(node)
    level = template_levels[chain.reverse.find { |_, vault| connection_keys?(vault) }.first]
    level.path if level && !same_connection?(merged_connection(chain.map(&:last)), level.connection)
  end

  def external_levels
    @external_levels ||= [(Level.new(GLOBAL_PATH, {}, global) unless bundled?(global[:address])), *levels.select { connection_keys?(it.own) && !bundled?(it.connection[:address]) }].compact
  end

  def levels
    @levels ||= Settings.where(owner_type: OWNER_TYPES).where.not(owner: system_configuration).includes(:owner).find_each.filter_map { level(it.owner, vault_of(it.data)) }
  end

  def level(owner, own)
    return if own.blank?

    Level.new(level_path(owner), own, merged_connection(hierarchy_vaults(owner)), !owner.is_a?(Node), owner)
  end

  def hierarchy_vaults(owner)
    return owner.path_nodes_settings.map { vault_of(it[:settings_version].data) } if owner.is_a?(Node)

    hierarchy_vaults(owner.is_a?(Template) ? owner.parent : owner.template) + [vault_of(owner.settings.data)]
  end

  def unfinished_change_connections
    Change.unscoped.where(id: BaseStep.where(parent_type: 'Change', finished_at: nil).select(:parent_id)).includes(:parent).find_each.filter_map do |change|
      vault = vault_of(change[:static_settings].to_h.deep_symbolize_keys[:data])
      [change, global.merge(vault.slice(*CONNECTION_KEYS))] if vault.present?
    end
  end

  def global
    @global ||= SecretVaultConfig.global.then do |config|
      CONNECTION_KEYS.index_with { config.try(it) }.merge(client_options: config.client_options.presence && JSON.parse(config.client_options)).compact
    end.deep_symbolize_keys
  end

  def level_path(owner)
    case owner
    when Node then owner.path
    when Template then owner.authorisation_path
    else "#{owner.template.authorisation_path}/versions/#{owner.version}"
    end
  end

  def vault_of(data)
    vault = data.to_h.deep_symbolize_keys[:vault]
    vault.is_a?(Hash) ? vault.transform_values { AesEncryption.decrypt_or_original(it) } : {}
  end

  def bundled?(address) = BUNDLED_ADDRESSES.include?(address.to_s.strip.delete_suffix('/').presence || BUNDLED_ADDRESSES.first)
  def bundled_credentials = level_paths { it.template && bundled?(it.connection[:address]) && own_service_credentials?(it.own) }
  def canonical_address(address) = address.to_s.strip.delete_suffix('/').downcase
  def client_options(connection) = parsed_client_options(connection).reject { |key, _| key.to_s.match?(RESERVED_CLIENT_OPTION) }
  def collect_findings = FINDINGS.keys.index_with { send(it).uniq.sort }.compact_blank
  def connection_keys?(vault) = vault.keys.intersect?(CONNECTION_KEYS)
  def credentials(connection) = CREDENTIAL_KEYS.map { it == :client_options ? client_options(connection) : connection[it].to_s }
  def global_external = bundled?(global[:address]) ? [] : [GLOBAL_PATH]
  def identity(connection) = [canonical_address(connection[:address]), client_options(connection)[:namespace].presence]
  def level_paths(&) = levels.select(&).map(&:path)
  def merged_connection(vaults) = vaults.reduce(global) { |merged, vault| merged.deep_merge(vault.slice(*CONNECTION_KEYS)) }
  def own_service_credentials?(vault) = SERVICE_CREDENTIAL_KEYS.any? { vault[it].present? && vault[it].to_s != global[it].to_s }
  def owner_vaults(node) = node.path_nodes_settings.map { [it[:owner], vault_of(it[:settings_version].data)] }
  def parsed_client_options(connection) = connection[:client_options].is_a?(String) ? JSON.parse(connection[:client_options].presence || '{}').deep_symbolize_keys : connection[:client_options].to_h
  def reference?(value) = value.is_a?(String) && value.start_with?(SECRET_REFERENCE_PREFIX)
  def same_connection?(one, other) = identity(one) == identity(other) && credentials(one) == credentials(other)
  def secret_reference = level_paths { |level| level.own.values.any? { reference?(it) } }
  def several_credentials = credential_groups.select { tls_differs?(it) || it.any?(&:template) }.flatten.map(&:path)
  def system_configuration = @system_configuration ||= SystemConfiguration.load_configuration
  def template_assets(owners) = Node.where(template_version_id: TemplateVersion.where(id: owners.grep(TemplateVersion)).or(TemplateVersion.where(template_id: owners.grep(Template))).select(:id))
  def template_without_address = level_paths { it.template && connection_keys?(it.own) && it.own[:address].blank? }
  def unmatched_change = unfinished_change_connections.filter_map { |change, connection| "/changes/#{change.id}" unless matches_level?(connection) || covered_by_conversion?(change.parent.path) }
end

ExternalVaultsCheck.main(__FILE__)
