"""Tests for Dogfood Round 5 and Round 6 fixes.

Covers: auth guard fixes, webhook ordering, telemetry validation,
rollback safety, adaptive_n edge cases, flaky predictor type safety,
privacy improvements, and input guardrails.
"""

from __future__ import annotations

import ipaddress
import re
import uuid
from unittest.mock import AsyncMock, MagicMock, patch

import pytest


# --- V-62 / V-79: Auth guard fixes ---


class TestSessionAccessNoneGuard:
    """V-62: _check_session_access should deny when both org_ids are None."""

    def test_none_org_ids_denied(self, client, auth_header):
        """Access should be denied when session has None org_id and user has None org_id."""
        session_id = str(uuid.uuid4())
        with patch("api.auth.verify_clerk_jwt") as mock_auth:
            mock_auth.return_value = {
                "sub": "user_test",
                "org_id": None,
                "permissions": ["*"],
            }
            with patch("api.db.get_session") as mock_get:
                mock_get.return_value = {
                    "id": session_id,
                    "org_id": None,
                    "status": "FINALIZED",
                }
                from fastapi.testclient import TestClient
                from api.main import app

                raw_client = TestClient(app)
                response = raw_client.get(
                    f"/v1/sessions/{session_id}",
                    headers=auth_header,
                )
                assert response.status_code in (403, 404)


class TestPrivateIPSSRFGuard:
    """V-79: Private IP SSRF guard should not be swallowed by except ValueError."""

    def test_private_ip_issuer_rejected(self):
        """A JWT with a private IP issuer should be rejected."""
        from api.auth import _validate_issuer

        with pytest.raises(ValueError, match="private IP"):
            _validate_issuer("http://192.168.1.100/clerk")

    def test_loopback_issuer_rejected(self):
        """A JWT with a loopback IP issuer should be rejected."""
        from api.auth import _validate_issuer

        with pytest.raises(ValueError, match="private IP"):
            _validate_issuer("http://127.0.0.1:8080/.well-known")

    def test_domain_issuer_allowed(self):
        """A JWT with a normal domain issuer should be allowed."""
        from api.auth import _validate_issuer

        # Should not raise (domain names pass through)
        result = _validate_issuer("https://clerk.example.com")
        assert "clerk.example.com" in result

    def test_public_ip_issuer_allowed(self):
        """A JWT with a public IP issuer should be allowed."""
        from api.auth import _validate_issuer

        result = _validate_issuer("https://8.8.8.8/clerk")
        assert "8.8.8.8" in result


class TestHasPermissionLogging:
    """V-88: has_permission should log WARNING (not DEBUG) when allowing without claims."""

    def test_no_perms_claim_returns_true_in_non_strict(self):
        """Without STRICT_PERMISSIONS, no permissions claim should still allow."""
        from api.auth import has_permission

        user = {"sub": "user_123"}  # No permissions, scope, or scp
        with patch.dict("os.environ", {}, clear=False):
            # Remove STRICT_PERMISSIONS if set
            import os
            os.environ.pop("STRICT_PERMISSIONS", None)
            result = has_permission(user, "read:runs")
            assert result is True

    def test_strict_mode_denies_no_perms(self):
        """With STRICT_PERMISSIONS=true, no permissions claim should deny."""
        from api.auth import has_permission

        user = {"sub": "user_123"}
        with patch.dict("os.environ", {"STRICT_PERMISSIONS": "true"}):
            result = has_permission(user, "read:runs")
            assert result is False


# --- V-81: Webhook signature before dedup ---


class TestWebhookSignatureOrdering:
    """V-81: Signature verification must happen before dedup check."""

    def test_invalid_signature_rejected_even_with_known_delivery_id(self, client):
        """Even if delivery_id was previously seen, invalid sig should return 401."""
        with patch("api.github_app.verify_webhook_signature") as mock_sig:
            mock_sig.return_value = False
            response = client.post(
                "/v1/webhooks/github",
                content=b'{"action": "test"}',
                headers={
                    "X-Hub-Signature-256": "sha256=invalid",
                    "X-GitHub-Delivery": "known-id-123",
                    "X-GitHub-Event": "ping",
                    "Content-Type": "application/json",
                },
            )
            # Should be 401 (not 200 "already_processed")
            assert response.status_code in (401, 503)

    def test_missing_webhook_secret_returns_503(self, client):
        """Missing GITHUB_WEBHOOK_SECRET should return 503, not 500."""
        with patch("api.github_app.verify_webhook_signature") as mock_sig:
            mock_sig.side_effect = RuntimeError("GITHUB_WEBHOOK_SECRET not set")
            response = client.post(
                "/v1/webhooks/github",
                content=b'{"action": "test"}',
                headers={
                    "X-Hub-Signature-256": "sha256=test",
                    "X-GitHub-Delivery": str(uuid.uuid4()),
                    "X-GitHub-Event": "ping",
                    "Content-Type": "application/json",
                },
            )
            assert response.status_code == 503


# --- V-82: Telemetry input validation ---


class TestTelemetryValidation:
    """V-82: Turn outcome endpoint should validate and constrain inputs."""

    def test_valid_outcome_accepted(self, client):
        """Valid outcome/reason_code should be accepted."""
        response = client.post(
            "/v1/telemetry/turn_outcome",
            json={
                "outcome": "BLOCKED",
                "reason_code": "blocked_tool_failure",
                "reason_detail": "session_timeout",
            },
        )
        assert response.status_code == 200
        assert response.json()["status"] == "ok"

    def test_unknown_outcome_ignored(self, client):
        """Unknown outcome should be ignored (not increment counters)."""
        response = client.post(
            "/v1/telemetry/turn_outcome",
            json={
                "outcome": "DROP_TABLE",
                "reason_code": "blocked_tool_failure",
            },
        )
        assert response.status_code == 200
        assert response.json()["status"] == "ignored"

    def test_unknown_reason_code_ignored(self, client):
        """Unknown reason_code should be ignored."""
        response = client.post(
            "/v1/telemetry/turn_outcome",
            json={
                "outcome": "BLOCKED",
                "reason_code": "malicious_value",
            },
        )
        assert response.status_code == 200
        assert response.json()["status"] == "ignored"

    def test_overlength_field_rejected(self, client):
        """Fields exceeding max_length should be rejected by Pydantic."""
        response = client.post(
            "/v1/telemetry/turn_outcome",
            json={
                "outcome": "A" * 100,  # max_length=50
                "reason_code": "timeout",
            },
        )
        assert response.status_code == 422


# --- V-85: run_agent input length guard ---


class TestRunAgentInputGuard:
    """V-85: run_agent should reject inputs over 50K chars."""

    def test_validate_input_imported(self):
        """_validate_input should be importable from core."""
        from autodebug.agent.core import _validate_input
        assert callable(_validate_input)

    def test_long_prompt_source_check(self):
        """Verify the 50K guard exists in run_agent source."""
        import inspect
        from autodebug.agent.core import run_agent

        source = inspect.getsource(run_agent)
        assert "50_000" in source or "50000" in source


# --- V-86: Rollback re-quarantine safety ---


class TestRollbackReQuarantineSafety:
    """V-86: Re-quarantine should only happen when revert PR succeeds."""

    def test_requarantine_guarded_by_revert_url(self):
        """Re-quarantine code should only run if revert_pr_url is set."""
        import inspect
        from api.ai.rollback import FixRollback

        source = inspect.getsource(FixRollback)
        # The re-quarantine block should be guarded by "if revert_pr_url:"
        assert "if revert_pr_url:" in source
        # And should log a warning when skipping
        assert "Skipping re-quarantine" in source


# --- V-92: adaptive_n target_power=1.0 ---


class TestAdaptiveNEdgeCases:
    """V-92: target_power=1.0 should return max_n, not min_n."""

    def test_target_power_one_returns_max(self):
        """100% detection power should return max_n."""
        from autodebug.detection.adaptive_n import calculate_adaptive_n

        result = calculate_adaptive_n(
            estimated_flake_rate=0.1,
            target_power=1.0,
            min_n=5,
            max_n=100,
        )
        assert result == 100

    def test_target_power_zero_returns_min(self):
        """0% detection power should return min_n."""
        from autodebug.detection.adaptive_n import calculate_adaptive_n

        result = calculate_adaptive_n(
            estimated_flake_rate=0.1,
            target_power=0.0,
            min_n=5,
            max_n=100,
        )
        assert result == 5

    def test_normal_power_in_range(self):
        """Normal power should return a value between min and max."""
        from autodebug.detection.adaptive_n import calculate_adaptive_n

        result = calculate_adaptive_n(
            estimated_flake_rate=0.1,
            target_power=0.95,
            min_n=5,
            max_n=100,
        )
        assert 5 <= result <= 100


# --- V-93: Flaky predictor type safety ---


class TestFlakyPredictorTypeSafety:
    """V-93: History fields should be int-coerced to avoid TypeError."""

    def test_none_history_fields_handled(self):
        """None values in history fields should not crash."""
        from api.ai.flaky_predictor import HistoricalFlakyPredictor

        predictor = HistoricalFlakyPredictor()

        # Mock _fetch_history to return None fields
        async def mock_history(*args, **kwargs):
            return {
                "total_runs": 20,
                "failed_runs": None,  # Should not crash
                "flaky_runs": None,
                "flaky_and_failed": None,
            }

        predictor._fetch_history = mock_history

        # Should not raise TypeError
        code_pred = predictor.predict("def test_foo(): pass", "test_foo")
        assert code_pred is not None

    def test_string_history_fields_handled(self):
        """String values in history fields should be coerced."""
        # Just verify the int() coercion logic exists
        assert int("20" or 0) == 20
        assert int(None or 0) == 0
        assert int(0 or 0) == 0


# --- V-70: PII dict key scanning ---


class TestPIIDictKeyScanning:
    """V-70: _iter_strings should scan dict keys, not just values."""

    def test_pii_in_dict_keys_detected(self):
        """PII in dictionary keys should be detected."""
        from worker.privacy import detect_pii

        payload = {"user@example.com": "some_value"}
        found, hits = detect_pii(payload)
        assert found is True
        assert any(h["type"] == "email" for h in hits)

    def test_pii_in_nested_dict_keys(self):
        """PII in nested dict keys should be detected."""
        from worker.privacy import detect_pii

        payload = {"data": {"sk_live_1234567890abcdef1234": "secret"}}
        found, hits = detect_pii(payload)
        assert found is True

    def test_normal_dict_no_false_positives(self):
        """Normal dict keys should not trigger PII detection."""
        from worker.privacy import detect_pii

        payload = {"name": "John", "age": 30, "city": "NYC"}
        found, hits = detect_pii(payload)
        assert found is False


# --- V-71: API token regex ---


class TestAPITokenRegex:
    """V-71: api_token regex should match hyphenated tokens."""

    def test_anthropic_key_detected(self):
        """sk-ant-... keys should be detected."""
        from worker.privacy import detect_pii

        payload = {"key": "sk-ant-api03-abcdefghijklmnop"}
        found, hits = detect_pii(payload)
        assert found is True
        assert any(h["type"] == "api_token" for h in hits)

    def test_openai_key_detected(self):
        """sk-... keys should be detected."""
        from worker.privacy import detect_pii

        payload = {"key": "sk_proj_abcdefghijklmnop"}
        found, hits = detect_pii(payload)
        assert found is True

    def test_github_token_detected(self):
        """ghp_... tokens should be detected."""
        from worker.privacy import detect_pii

        payload = {"token": "ghp_abcdefghijklmnopqrstuvwxyz0123456789"}
        found, hits = detect_pii(payload)
        assert found is True
        assert any(h["type"] == "github_token" for h in hits)


# --- V-72: Thread-safe broker ---


class TestThreadSafeBroker:
    """V-72: get_broker should be thread-safe with double-checked locking."""

    def test_broker_lock_exists(self):
        """The _broker_lock should exist in worker.broker."""
        from worker.broker import _broker_lock
        import threading
        assert isinstance(_broker_lock, type(threading.Lock()))

    def test_get_broker_returns_same_instance(self):
        """get_broker should return the same instance on repeated calls."""
        import worker.broker as wb
        # Reset state
        old_broker = wb.broker
        wb.broker = None
        try:
            b1 = wb.get_broker()
            b2 = wb.get_broker()
            assert b1 is b2
        finally:
            wb.broker = old_broker


# --- V-69: Bandit divide-by-zero ---


class TestBanditDivideByZero:
    """V-69: sample() should handle x+y==0 edge case."""

    def test_sample_guard_in_source(self):
        """Verify the divide-by-zero guard exists in StrategyStats.sample()."""
        import inspect
        from autodebug.strategies.bandit_selector import StrategyStats

        source = inspect.getsource(StrategyStats.sample)
        assert "x + y == 0" in source


# --- V-94: Proof certificate run_number ---


class TestProofCertificateRunNumber:
    """V-94: Validation run numbers should be 1-indexed."""

    def test_run_numbers_start_at_one(self):
        """generate_proof_certificate should produce 1-indexed run numbers."""
        from autodebug.autonomous.proof import generate_proof_certificate

        cert = generate_proof_certificate(
            original_run_id="run-123",
            original_exception={"type": "AssertionError", "message": "fail"},
            hypothesis_id="hyp-456",
            patch_hash="abc123",
            validation_results=[
                {"exit_code": 0, "passed": True, "output": "ok"},
                {"exit_code": 0, "passed": True, "output": "ok"},
                {"exit_code": 1, "passed": False, "output": "fail"},
            ],
            verdict="PARTIAL",
        )
        assert cert.validation_runs[0].run_number == 1
        assert cert.validation_runs[1].run_number == 2
        assert cert.validation_runs[2].run_number == 3


# --- V-110: Regression guard confidence ---


class TestRegressionGuardConfidence:
    """V-110: New signature confidence boost should be +0.15."""

    def test_confidence_boost_is_015(self):
        """Source should contain 0.15 boost for new signatures."""
        import inspect
        from autodebug.detection.regression_guard import RegressionGuard

        source = inspect.getsource(RegressionGuard)
        assert "0.15" in source


# --- V-84: Device flow slow_down cap ---


class TestDeviceFlowSlowDownCap:
    """V-84: Device flow slow_down interval should be capped."""

    def test_slow_down_cap_in_source(self):
        """Source should cap the interval at 60s."""
        import inspect
        import autodebug.oauth as oauth_mod

        source = inspect.getsource(oauth_mod)
        # Should have min(..., 60) or similar
        assert "min(" in source and "60" in source


# --- V-104: Test generator dotted imports ---


class TestGeneratorDottedImports:
    """V-104: Generated test imports should use dotted module paths."""

    def test_import_builder_in_source(self):
        """Source should walk up looking for __init__.py."""
        import inspect
        from autodebug.autonomous.test_generator import generate_tests

        source = inspect.getsource(generate_tests)
        assert "__init__.py" in source


# --- V-105: LLM client timeouts ---


class TestLLMClientTimeouts:
    """V-105: Anthropic/OpenAI clients should have timeouts."""

    def test_anthropic_timeout_in_test_generator(self):
        """test_generator._call_anthropic should set timeout."""
        import inspect
        from autodebug.autonomous.test_generator import _call_anthropic

        source = inspect.getsource(_call_anthropic)
        assert "timeout" in source

    def test_openai_timeout_in_test_generator(self):
        """test_generator._call_openai should set timeout."""
        import inspect
        from autodebug.autonomous.test_generator import _call_openai

        source = inspect.getsource(_call_openai)
        assert "timeout" in source

    def test_anthropic_timeout_in_hypothesis(self):
        """hypothesis._call_anthropic should set timeout."""
        import inspect
        from autodebug.autonomous.hypothesis import _call_anthropic

        source = inspect.getsource(_call_anthropic)
        assert "timeout" in source

    def test_openai_timeout_in_hypothesis(self):
        """hypothesis._call_openai should set timeout."""
        import inspect
        from autodebug.autonomous.hypothesis import _call_openai

        source = inspect.getsource(_call_openai)
        assert "timeout" in source
