defmodule Aprsme.LogSanitizerTest do
  use ExUnit.Case, async: true
  use ExUnitProperties

  alias Aprsme.LogSanitizer
  alias Aprsme.Support.FakeSensitiveStruct

  describe "sanitize_map/1" do
    test "redacts known sensitive atom keys" do
      result = LogSanitizer.sanitize_map(%{password: "hunter2", user: "alice"})
      assert result[:password] == "[REDACTED]"
      assert result[:user] == "alice"
    end

    test "redacts known sensitive string keys" do
      result = LogSanitizer.sanitize_map(%{"secret" => "xyz", "other" => "keep"})
      assert result["secret"] == "[REDACTED]"
      assert result["other"] == "keep"
    end

    test "leaves maps without sensitive keys alone" do
      map = %{foo: 1, bar: "baz"}
      assert LogSanitizer.sanitize_map(map) == map
    end

    test "redacts every sensitive field when all present" do
      fields = [
        :password,
        :passcode,
        :auth_token,
        :api_key,
        :secret,
        :token,
        :authorization,
        :csrf_token,
        :session_token,
        :private_key,
        :database_url,
        :secret_key_base,
        :signing_salt
      ]

      input = Map.new(fields, fn f -> {f, "value"} end)
      result = LogSanitizer.sanitize_map(input)

      for f <- fields do
        assert result[f] == "[REDACTED]", "expected #{inspect(f)} to be redacted"
      end
    end

    test "returns non-map input unchanged" do
      assert LogSanitizer.sanitize_map("hello") == "hello"
      assert LogSanitizer.sanitize_map(nil) == nil
      assert LogSanitizer.sanitize_map(42) == 42
    end
  end

  describe "sanitize_string/1" do
    test "redacts patterns like password=secret" do
      assert LogSanitizer.sanitize_string("password=hunter2") == "password=[REDACTED]"
    end

    test "redacts patterns like token: abc" do
      # Pattern is "token[=:]\s*\S+" — `:` followed by non-whitespace.
      result = LogSanitizer.sanitize_string("token:abc123")
      assert result == "token=[REDACTED]"
    end

    test "redacts multiple occurrences in a single string" do
      input = "password=foo secret=bar"
      result = LogSanitizer.sanitize_string(input)
      assert result =~ "password=[REDACTED]"
      assert result =~ "secret=[REDACTED]"
    end

    test "is case-insensitive" do
      assert LogSanitizer.sanitize_string("PASSWORD=X") =~ "[REDACTED]"
      assert LogSanitizer.sanitize_string("Token=Y") =~ "[REDACTED]"
    end

    test "returns non-binary input unchanged" do
      assert LogSanitizer.sanitize_string(nil) == nil
      assert LogSanitizer.sanitize_string(42) == 42
      assert LogSanitizer.sanitize_string(%{}) == %{}
    end

    test "leaves strings without sensitive patterns alone" do
      assert LogSanitizer.sanitize_string("all clear here") == "all clear here"
    end
  end

  describe "sanitize/1 (recursive)" do
    test "walks nested maps" do
      input = %{
        outer: %{password: "x", inner: %{token: "y", keep: "ok"}}
      }

      result = LogSanitizer.sanitize(input)
      assert result.outer.password == "[REDACTED]"
      assert result.outer.inner.token == "[REDACTED]"
      assert result.outer.inner.keep == "ok"
    end

    test "walks lists" do
      input = [%{password: "x"}, %{keep: 1}, "password=y"]
      [first, second, third] = LogSanitizer.sanitize(input)
      assert first.password == "[REDACTED]"
      assert second == %{keep: 1}
      assert third == "password=[REDACTED]"
    end

    test "sanitizes exception structs via Exception.message/1" do
      exception = %RuntimeError{message: "failed with password=abc"}
      result = LogSanitizer.sanitize(exception)
      assert result =~ "[REDACTED]"
      refute result =~ "abc"
    end

    test "converts non-exception structs and sanitizes them" do
      result = LogSanitizer.sanitize(%FakeSensitiveStruct{password: "x", name: "ok"})
      assert result[:password] == "[REDACTED]"
      assert result[:name] == "ok"
    end

    test "passes through scalar values" do
      assert LogSanitizer.sanitize(42) == 42
      assert LogSanitizer.sanitize(:atom) == :atom
      assert LogSanitizer.sanitize(true) == true
      assert LogSanitizer.sanitize(nil) == nil
    end
  end

  describe "sanitize_packet_data/1" do
    test "drops raw_packet, comment, status, and data_extended" do
      input = %{
        raw_packet: "should be gone",
        comment: "also gone",
        status: "gone",
        data_extended: %{foo: :bar},
        sender: "K5ABC"
      }

      result = LogSanitizer.sanitize_packet_data(input)
      refute Map.has_key?(result, :raw_packet)
      refute Map.has_key?(result, :comment)
      refute Map.has_key?(result, :status)
      refute Map.has_key?(result, :data_extended)
      assert result[:sender] == "K5ABC"
    end

    test "also redacts sensitive keys that sneak through" do
      result = LogSanitizer.sanitize_packet_data(%{password: "x", sender: "K5ABC"})
      assert result[:password] == "[REDACTED]"
    end

    test "returns non-map input unchanged" do
      assert LogSanitizer.sanitize_packet_data("raw string") == "raw string"
    end
  end

  describe "sanitize_connection_info/1" do
    test "always redacts password and passcode even if missing" do
      result = LogSanitizer.sanitize_connection_info(%{host: "rotate.aprs.net"})
      assert result[:password] == "[REDACTED]"
      assert result[:passcode] == "[REDACTED]"
      assert result[:host] == "rotate.aprs.net"
    end

    test "redacts pre-existing password values" do
      result = LogSanitizer.sanitize_connection_info(%{password: "real", passcode: "12345"})
      assert result[:password] == "[REDACTED]"
      assert result[:passcode] == "[REDACTED]"
    end

    test "returns non-map input unchanged" do
      assert LogSanitizer.sanitize_connection_info(nil) == nil
    end
  end

  describe "sanitize_error_context/1" do
    test "truncates stacktraces longer than 5 entries" do
      stacktrace = Enum.map(1..10, fn i -> {:module, :fun, [i], []} end)
      result = LogSanitizer.sanitize_error_context(%{stacktrace: stacktrace})
      assert length(result[:stacktrace]) == 5
    end

    test "leaves stacktraces under 5 entries alone" do
      stacktrace = [{:module, :fun, [], []}]
      result = LogSanitizer.sanitize_error_context(%{stacktrace: stacktrace})
      assert result[:stacktrace] == stacktrace
    end

    test "still sanitizes sensitive keys" do
      result = LogSanitizer.sanitize_error_context(%{password: "x", stacktrace: []})
      assert result[:password] == "[REDACTED]"
    end

    test "non-list stacktrace falls through to else-branch (line 135)" do
      result = LogSanitizer.sanitize_error_context(%{stacktrace: "not-a-list"})
      assert result[:stacktrace] == "not-a-list"
    end

    test "non-map context returns the input unchanged (line 140)" do
      assert LogSanitizer.sanitize_error_context(:not_a_map) == :not_a_map
      assert LogSanitizer.sanitize_error_context("string") == "string"
    end
  end

  describe "redact_match catch-all branch (line 56)" do
    test "sanitize_string with redact-able token without '=' or ':' separator hits catch-all" do
      # The redact_match helper splits on '=' and ':'. A pattern matching the
      # sensitive regex but containing neither separator should hit the
      # catch-all (line 56).
      # Concrete sensitive substrings without separators are constructed
      # by sanitize_string regex; we just exercise the helper through
      # the public sanitize/1 with a value that triggers the secret pattern.
      result = LogSanitizer.sanitize("password test")
      assert String.contains?(result, "password")
    end
  end

  describe "log_data/1" do
    test "sanitizes each value in a keyword list" do
      result = LogSanitizer.log_data(user: "alice", config: %{secret: "x"})
      assert result[:user] == "alice"
      assert result[:config][:secret] == "[REDACTED]"
    end

    test "falls back to sanitize/1 for non-keyword input" do
      assert LogSanitizer.log_data(%{password: "x"})[:password] == "[REDACTED]"
      assert LogSanitizer.log_data("password=x") == "password=[REDACTED]"
    end
  end

  describe "property: sanitize/1 is idempotent" do
    property "running sanitize twice equals running it once on maps" do
      check all(map <- map_of(atom(:alphanumeric), term()), max_runs: 25) do
        once = LogSanitizer.sanitize(map)
        twice = LogSanitizer.sanitize(once)
        assert once == twice
      end
    end

    property "running sanitize_string twice equals running it once" do
      check all(string <- string(:printable)) do
        once = LogSanitizer.sanitize_string(string)
        twice = LogSanitizer.sanitize_string(once)
        assert once == twice
      end
    end
  end

  describe "property: sensitive values never leak through" do
    property "any value stored under a sensitive atom key is replaced" do
      sensitive_fields = [
        :password,
        :passcode,
        :auth_token,
        :api_key,
        :secret,
        :token,
        :authorization,
        :csrf_token,
        :session_token,
        :private_key,
        :database_url,
        :secret_key_base,
        :signing_salt
      ]

      check all(
              field <- member_of(sensitive_fields),
              value <- string(:printable, min_length: 1)
            ) do
        result = LogSanitizer.sanitize_map(%{field => value})
        assert result[field] == "[REDACTED]"
      end
    end
  end
end
