# Copyright © 2025-26 l5yth & contributors
#
# Licensed 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.

# frozen_string_literal: true

module PotatoMesh
  module App
    module Federation
      # Validate a remote +/.well-known+ document, including signature checks
      # against the supplied public key.
      #
      # @param document [Hash] decoded well-known document.
      # @param domain [String] expected sanitized domain.
      # @param pubkey [String] expected canonical PEM public key.
      # @return [Array(Boolean, String, nil)] tuple containing the validation
      #   result and an optional human-readable failure reason.
      def validate_well_known_document(document, domain, pubkey)
        unless document.is_a?(Hash)
          return [false, "document is not an object"]
        end

        remote_pubkey = sanitize_public_key_pem(document["public_key"] || document["publicKey"])
        return [false, "public key missing"] unless remote_pubkey
        return [false, "public key mismatch"] unless remote_pubkey == pubkey

        remote_domain = string_or_nil(document["domain"])
        return [false, "domain missing"] unless remote_domain
        return [false, "domain mismatch"] unless remote_domain.casecmp?(domain)

        algorithm = string_or_nil(document["signature_algorithm"] || document["signatureAlgorithm"])
        unless algorithm&.casecmp?(PotatoMesh::Config.instance_signature_algorithm)
          return [false, "unsupported signature algorithm"]
        end

        signed_payload_b64 = string_or_nil(document["signed_payload"] || document["signedPayload"])
        signature_b64 = string_or_nil(document["signature"])
        return [false, "missing signed payload"] unless signed_payload_b64
        return [false, "missing signature"] unless signature_b64

        signed_payload = Base64.strict_decode64(signed_payload_b64)
        signature = Base64.strict_decode64(signature_b64)
        key = OpenSSL::PKey::RSA.new(remote_pubkey)
        unless key.verify(OpenSSL::Digest::SHA256.new, signature, signed_payload)
          return [false, "invalid well-known signature"]
        end

        payload = JSON.parse(signed_payload)
        unless payload.is_a?(Hash)
          return [false, "signed payload is not an object"]
        end

        payload_domain = string_or_nil(payload["domain"])
        payload_pubkey = sanitize_public_key_pem(payload["public_key"] || payload["publicKey"])
        return [false, "signed payload domain mismatch"] unless payload_domain&.casecmp?(domain)
        return [false, "signed payload public key mismatch"] unless payload_pubkey == pubkey

        [true, nil]
      rescue ArgumentError, OpenSSL::PKey::PKeyError => e
        [false, e.message]
      rescue JSON::ParserError => e
        [false, "signed payload JSON error: #{e.message}"]
      end

      # Confirm a remote +/api/nodes+ payload contains a sufficient set of
      # recently active nodes.
      #
      # @param nodes [Object] decoded array of remote node entries.
      # @return [Array(Boolean, String, nil)] tuple of (is_fresh, optional reason).
      def validate_remote_nodes(nodes)
        unless nodes.is_a?(Array)
          return [false, "node response is not an array"]
        end

        if nodes.length < PotatoMesh::Config.remote_instance_min_node_count
          return [false, "insufficient nodes"]
        end

        latest = nodes.filter_map do |node|
          next unless node.is_a?(Hash)

          last_heard_values = []
          last_heard_values << coerce_integer(node["last_heard"])
          last_heard_values << coerce_integer(node["lastHeard"])
          last_heard_values.compact.max
        end.compact.max

        return [false, "missing last_heard data"] unless latest

        cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
        return [false, "node data is stale"] if latest < cutoff

        [true, nil]
      end
    end
  end
end
