# 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
      # Process-wide memo for the most recently emitted self-registration
      # decision.  Sinatra spins up a fresh app instance per request so a
      # plain instance variable would not survive across calls; storing the
      # state on the module itself keeps the dedupe stable for the lifetime
      # of the worker process.
      @self_registration_log_state = { mutex: Mutex.new, last: nil }

      # Accessor for the dedupe state used by {#ensure_self_instance_record!}.
      #
      # @return [Hash{Symbol => Object}] mutable state hash holding +:mutex+ and +:last+.
      def self.self_registration_log_state
        @self_registration_log_state
      end

      # Reset the dedupe memo.  Intended for tests; production code never
      # needs to clear the state because each process starts fresh.
      #
      # @return [void]
      def self.reset_self_registration_log_state!
        state = @self_registration_log_state
        state[:mutex].synchronize { state[:last] = nil }
      end

      # Resolve the canonical domain for the running instance.
      #
      # @return [String, nil] sanitized instance domain or nil outside production.
      # @raise [RuntimeError] when the domain cannot be determined in production.
      def self_instance_domain
        sanitized = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
        return sanitized if sanitized

        unless production_environment?
          debug_log(
            "INSTANCE_DOMAIN unavailable; skipping self instance domain",
            context: "federation.instances",
            app_env: string_or_nil(ENV["APP_ENV"]),
            rack_env: string_or_nil(ENV["RACK_ENV"]),
            source: app_constant(:INSTANCE_DOMAIN_SOURCE),
          )
          return nil
        end

        raise "INSTANCE_DOMAIN could not be determined"
      end

      # Determine whether the local instance should persist its own record.
      #
      # @param domain [String, nil] candidate domain for the running instance.
      # @return [Array(Boolean, String, nil)] tuple containing a decision flag and an optional reason.
      def self_instance_registration_decision(domain)
        source = app_constant(:INSTANCE_DOMAIN_SOURCE)
        return [false, "INSTANCE_DOMAIN source is #{source}"] unless source == :environment

        sanitized = sanitize_instance_domain(domain)
        return [false, "INSTANCE_DOMAIN missing or invalid"] unless sanitized

        ip = ip_from_domain(sanitized)
        if ip && restricted_ip_address?(ip)
          return [false, "INSTANCE_DOMAIN resolves to restricted IP"]
        end

        [true, nil]
      end

      # Build the canonical attribute hash describing the local instance.
      #
      # @return [Hash] populated instance attribute hash.
      def self_instance_attributes
        domain = self_instance_domain
        last_update = latest_node_update_timestamp || Time.now.to_i
        cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
        db = open_database(readonly: true)
        nodes_count = active_node_count_since(cutoff, db: db)
        mc_count = active_node_count_since_for_protocol(cutoff, "meshcore", db: db)
        mt_count = active_node_count_since_for_protocol(cutoff, "meshtastic", db: db)
        {
          id: app_constant(:SELF_INSTANCE_ID),
          domain: domain,
          pubkey: app_constant(:INSTANCE_PUBLIC_KEY_PEM),
          name: sanitized_site_name,
          version: app_constant(:APP_VERSION),
          channel: sanitized_channel,
          frequency: sanitized_frequency,
          latitude: PotatoMesh::Config.map_center_lat,
          longitude: PotatoMesh::Config.map_center_lon,
          last_update_time: last_update,
          is_private: private_mode?,
          contact_link: sanitized_contact_link,
          nodes_count: nodes_count,
          meshcore_nodes_count: mc_count,
          meshtastic_nodes_count: mt_count,
          # Forward-compat stub (SPEC FS2): always 0 until a Reticulum ingestor
          # exists, but signed now so the shape need not change again.
          reticulum_nodes_count: 0,
        }
      ensure
        db&.close
      end

      # Sign a canonical instance attribute set with the local private key.
      #
      # @param attributes [Hash] canonical instance attributes.
      # @return [String] base64-encoded RSA-SHA256 signature.
      def sign_instance_attributes(attributes)
        payload = canonical_instance_payload(attributes)
        Base64.strict_encode64(
          app_constant(:INSTANCE_PRIVATE_KEY).sign(OpenSSL::Digest::SHA256.new, payload),
        )
      end

      # Compose the JSON-friendly announcement payload sent to peers.
      #
      # @param attributes [Hash] canonical instance attributes.
      # @param signature [String] base64-encoded signature.
      # @return [Hash] payload with nil entries removed.
      def instance_announcement_payload(attributes, signature)
        payload = {
          "id" => attributes[:id],
          "domain" => attributes[:domain],
          "public_key" => attributes[:pubkey],
          "name" => attributes[:name],
          "version" => attributes[:version],
          "channel" => attributes[:channel],
          "frequency" => attributes[:frequency],
          "latitude" => attributes[:latitude],
          "longitude" => attributes[:longitude],
          "last_update" => attributes[:last_update_time],
          "is_private" => attributes[:is_private],
          "contact_link" => attributes[:contact_link],
          "nodes_count" => attributes[:nodes_count],
          "meshcore_nodes_count" => attributes[:meshcore_nodes_count],
          "meshtastic_nodes_count" => attributes[:meshtastic_nodes_count],
          "reticulum_nodes_count" => attributes[:reticulum_nodes_count],
          "signature" => signature,
          "signature_version" => PotatoMesh::Config.federation_signature_version,
        }
        payload.reject { |_, value| value.nil? }
      end

      # Persist the local instance record when registration is allowed.
      #
      # @return [Array(Hash, String)] tuple of (attributes, signature) suitable
      #   for direct reuse by the announcer thread.
      def ensure_self_instance_record!
        attributes = self_instance_attributes
        signature = sign_instance_attributes(attributes)
        db = nil
        allowed, reason = self_instance_registration_decision(attributes[:domain])
        # Decisions are stable per process while INSTANCE_DOMAIN_SOURCE
        # remains the same — without dedupe, the federation banner on every
        # page navigation produced one log line apiece.  Only emit when the
        # tuple changes so operators still see the first decision (and any
        # later flip) without the spam.
        sentinel = [allowed, reason, attributes[:domain]]
        state = PotatoMesh::App::Federation.self_registration_log_state
        should_log = state[:mutex].synchronize do
          changed = state[:last] != sentinel
          state[:last] = sentinel if changed
          changed
        end
        if allowed
          db = open_database
          upsert_instance_record(db, attributes, signature)
          if should_log
            debug_log(
              "Registered self instance record",
              context: "federation.instances",
              domain: attributes[:domain],
              instance_id: attributes[:id],
            )
          end
        elsif should_log
          debug_log(
            "Skipped self instance registration",
            context: "federation.instances",
            domain: attributes[:domain],
            reason: reason,
          )
        end
        [attributes, signature]
      ensure
        db&.close
      end
    end
  end
end
