"""
Tests for runtime.vault.key_vault — Secure local API key storage.

Covers: store/retrieve roundtrip, encryption at rest, machine-specific
derivation, passphrase support, key masking, key testing (mocked HTTP),
export/import, rotation, and file permission checks.
"""

import json
import os
import sys
from http.client import HTTPResponse
from io import BytesIO
from pathlib import Path
from unittest import mock
from unittest.mock import MagicMock, patch

import pytest

# Ensure both src/ and the repo root are importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from runtime.vault.key_vault import (
    KeyVault,
    KeyInfo,
    _derive_fernet_key,
    _get_machine_id,
    _mask_key,
    _test_provider_key,
)


@pytest.fixture()
def vault(tmp_path: Path) -> KeyVault:
    """Create a KeyVault backed by a temp directory."""
    vault_file = tmp_path / "vault.json"
    return KeyVault(vault_path=vault_file)


@pytest.fixture()
def vault_path(tmp_path: Path) -> Path:
    """Return the vault file path for direct file inspection."""
    return tmp_path / "vault.json"


@pytest.fixture()
def vault_with_path(tmp_path: Path) -> tuple[KeyVault, Path]:
    """Return both vault instance and its file path."""
    vault_file = tmp_path / "vault.json"
    return KeyVault(vault_path=vault_file), vault_file


# ── Store and Retrieve ───────────────────────────────────────────────────


class TestStoreAndRetrieve:
    def test_roundtrip(self, vault: KeyVault) -> None:
        """Store a key, retrieve it — should get back the exact same string."""
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        assert vault.get_key("claude") == "sk-ant-api03-ABCDEF1234567890"

    def test_multiple_providers(self, vault: KeyVault) -> None:
        """Store keys for several providers and retrieve each independently."""
        vault.store_key("claude", "claude-key-abcd1234")
        vault.store_key("gemini", "gemini-key-efgh5678")
        vault.store_key("gpt", "gpt-key-ijkl9012abcd")

        assert vault.get_key("claude") == "claude-key-abcd1234"
        assert vault.get_key("gemini") == "gemini-key-efgh5678"
        assert vault.get_key("gpt") == "gpt-key-ijkl9012abcd"

    def test_overwrite(self, vault: KeyVault) -> None:
        """Storing a new key for the same provider replaces the old one."""
        vault.store_key("claude", "first-key-value-here")
        vault.store_key("claude", "second-key-value-here")
        assert vault.get_key("claude") == "second-key-value-here"

    def test_get_nonexistent_returns_none(self, vault: KeyVault) -> None:
        """Getting a key for a provider that was never stored returns None."""
        assert vault.get_key("nonexistent") is None

    def test_case_insensitive_provider(self, vault: KeyVault) -> None:
        """Provider names are normalized to lowercase."""
        vault.store_key("Claude", "my-secret-key-12345")
        assert vault.get_key("claude") == "my-secret-key-12345"
        assert vault.get_key("CLAUDE") == "my-secret-key-12345"

    def test_whitespace_trimmed(self, vault: KeyVault) -> None:
        """Leading/trailing whitespace is stripped from provider and key."""
        vault.store_key("  Claude  ", "  sk-ant-api03-trimtest  ")
        assert vault.get_key("claude") == "sk-ant-api03-trimtest"

    def test_empty_provider_raises(self, vault: KeyVault) -> None:
        """Empty provider name raises ValueError."""
        with pytest.raises(ValueError, match="cannot be empty"):
            vault.store_key("", "some-key-value-1234")

    def test_empty_key_raises(self, vault: KeyVault) -> None:
        """Empty API key raises ValueError."""
        with pytest.raises(ValueError, match="cannot be empty"):
            vault.store_key("claude", "")

    def test_special_characters_roundtrip(self, vault: KeyVault) -> None:
        """Keys with special characters survive encryption roundtrip."""
        special = "key+with/special=chars!@#$%^&*()_{}|:<>?"
        vault.store_key("test", special)
        assert vault.get_key("test") == special

    def test_long_key_roundtrip(self, vault: KeyVault) -> None:
        """Very long keys survive encryption roundtrip."""
        long_key = "x" * 2048
        vault.store_key("test", long_key)
        assert vault.get_key("test") == long_key


# ── Encryption at Rest ───────────────────────────────────────────────────


class TestEncryptionAtRest:
    def test_raw_key_not_in_file(self, vault_with_path: tuple[KeyVault, Path]) -> None:
        """The raw API key must never appear in the vault file."""
        vault, vault_file = vault_with_path
        raw_key = "sk-ant-api03-THIS-IS-A-SECRET-KEY-VALUE"
        vault.store_key("claude", raw_key)

        file_contents = vault_file.read_text("utf-8")
        assert raw_key not in file_contents

    def test_different_ciphertext_each_store(
        self, vault_with_path: tuple[KeyVault, Path]
    ) -> None:
        """Storing the same key twice produces different ciphertext (random IV)."""
        vault, vault_file = vault_with_path
        vault.store_key("provider1", "identical-key-value-abcd")
        data1 = json.loads(vault_file.read_text("utf-8"))
        enc1 = data1["keys"]["provider1"]

        vault.store_key("provider2", "identical-key-value-abcd")
        data2 = json.loads(vault_file.read_text("utf-8"))
        enc2 = data2["keys"]["provider2"]

        assert enc1 != enc2

    def test_vault_file_is_valid_json(
        self, vault_with_path: tuple[KeyVault, Path]
    ) -> None:
        """The vault file must be parseable JSON with required structure."""
        vault, vault_file = vault_with_path
        vault.store_key("claude", "sk-some-key-value-1234")

        data = json.loads(vault_file.read_text("utf-8"))
        assert "version" in data
        assert "salt" in data
        assert "keys" in data
        assert "metadata" in data
        assert data["version"] == 2

    def test_metadata_stored(self, vault_with_path: tuple[KeyVault, Path]) -> None:
        """Metadata (added_at, provider label) is persisted alongside keys."""
        vault, vault_file = vault_with_path
        vault.store_key("claude", "sk-ant-api03-abcdef1234")

        data = json.loads(vault_file.read_text("utf-8"))
        meta = data["metadata"]["anthropic"]  # 'claude' alias maps to 'anthropic'
        assert "added_at" in meta
        assert meta["provider"] == "anthropic"


# ── Machine-Specific Derivation ─────────────────────────────────────────


class TestMachineSpecificDerivation:
    def test_machine_id_is_deterministic(self) -> None:
        """Calling _get_machine_id() twice on the same machine gives identical results."""
        id1 = _get_machine_id()
        id2 = _get_machine_id()
        assert id1 == id2

    def test_machine_id_is_32_bytes(self) -> None:
        """Machine ID is a SHA-256 digest (32 bytes)."""
        assert len(_get_machine_id()) == 32

    def test_different_machine_different_derivation(self, tmp_path: Path) -> None:
        """Simulating a different machine makes decryption fail."""
        vault_file = tmp_path / "vault.json"
        vault = KeyVault(vault_path=vault_file)
        vault.store_key("claude", "real-secret-key-1234")

        # Patch _get_machine_id to simulate a different machine
        fake_id = b"\x00" * 32
        with patch("runtime.vault.key_vault._get_machine_id", return_value=fake_id):
            # Reading with wrong machine ID should fail decryption
            vault2 = KeyVault(vault_path=vault_file)
            assert vault2.get_key("claude") is None

    def test_derived_key_changes_with_machine(self) -> None:
        """Fernet key derivation produces different output for different machine IDs."""
        salt = b"\x00" * 32

        real_key = _derive_fernet_key(salt, None)

        fake_id = b"\xff" * 32
        with patch("runtime.vault.key_vault._get_machine_id", return_value=fake_id):
            fake_key = _derive_fernet_key(salt, None)

        assert real_key != fake_key


# ── Passphrase Support ───────────────────────────────────────────────────


class TestPassphraseSupport:
    def test_passphrase_roundtrip(self, vault: KeyVault) -> None:
        """Key stored with passphrase can be retrieved with the same passphrase."""
        vault.store_key("claude", "secret-key-abcdef", passphrase="my-pass")
        assert vault.get_key("claude", passphrase="my-pass") == "secret-key-abcdef"

    def test_wrong_passphrase_fails(self, vault: KeyVault) -> None:
        """Wrong passphrase returns None (decryption failure)."""
        vault.store_key("claude", "secret-key-abcdef", passphrase="correct")
        assert vault.get_key("claude", passphrase="wrong") is None

    def test_no_passphrase_fails_when_stored_with_one(
        self, vault: KeyVault
    ) -> None:
        """Omitting passphrase when one was used to store fails."""
        vault.store_key("claude", "secret-key-abcdef", passphrase="my-pass")
        assert vault.get_key("claude") is None  # No passphrase = wrong passphrase

    def test_passphrase_stored_shows_masked_placeholder(
        self, vault: KeyVault
    ) -> None:
        """list_keys() shows a placeholder when key was stored with passphrase."""
        vault.store_key("claude", "secret-key-abcdef", passphrase="my-pass")
        keys = vault.list_keys()
        assert len(keys) == 1
        # Cannot decrypt without passphrase, so should show placeholder
        assert keys[0].masked_key == "****...****"


# ── Key Masking ──────────────────────────────────────────────────────────


class TestKeyMasking:
    def test_long_key(self) -> None:
        assert _mask_key("sk-ant-api03-ABCDEF1234567890") == "sk-a...7890"

    def test_medium_key(self) -> None:
        """Keys of 7-8 chars use 3-char prefix/suffix."""
        assert _mask_key("abcdefgh") == "abc...fgh"

    def test_short_key(self) -> None:
        """Keys <= 6 chars are fully hidden."""
        assert _mask_key("abc") == "***"
        assert _mask_key("abcdef") == "***"

    def test_nine_char_key(self) -> None:
        """Keys of 9+ chars use 4-char prefix/suffix."""
        assert _mask_key("123456789") == "1234...6789"


# ── List Keys ────────────────────────────────────────────────────────────


class TestListKeys:
    def test_empty_vault(self, vault: KeyVault) -> None:
        assert vault.list_keys() == []

    def test_lists_with_metadata(self, vault: KeyVault) -> None:
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        vault.store_key("gemini", "gemini-key-xyz-1234567890")

        keys = vault.list_keys()
        assert len(keys) == 2
        providers = [k.provider for k in keys]
        assert providers == ["anthropic", "google"]  # sorted (aliases resolved)

        for k in keys:
            assert isinstance(k, KeyInfo)
            assert "..." in k.masked_key
            assert k.added_at != "unknown"

    def test_list_returns_provider_labels(self, vault: KeyVault) -> None:
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        keys = vault.list_keys()
        assert keys[0].provider_label == "anthropic"


# ── Delete Keys ──────────────────────────────────────────────────────────


class TestDeleteKey:
    def test_delete_existing(self, vault: KeyVault) -> None:
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        assert vault.delete_key("claude") is True
        assert vault.get_key("claude") is None

    def test_delete_nonexistent(self, vault: KeyVault) -> None:
        assert vault.delete_key("nonexistent") is False

    def test_delete_preserves_others(self, vault: KeyVault) -> None:
        vault.store_key("claude", "claude-key-abcdef1234")
        vault.store_key("gemini", "gemini-key-ghijkl5678")
        vault.delete_key("claude")
        assert vault.get_key("gemini") == "gemini-key-ghijkl5678"

    def test_delete_removes_metadata(
        self, vault_with_path: tuple[KeyVault, Path]
    ) -> None:
        vault, vault_file = vault_with_path
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        vault.delete_key("claude")

        data = json.loads(vault_file.read_text("utf-8"))
        assert "claude" not in data["keys"]
        assert "claude" not in data["metadata"]


# ── Key Testing (Mock HTTP) ─────────────────────────────────────────────


class TestKeyTesting:
    def _make_mock_response(self, status: int = 200) -> MagicMock:
        """Create a mock urllib response."""
        mock_resp = MagicMock()
        mock_resp.status = status
        mock_resp.__enter__ = MagicMock(return_value=mock_resp)
        mock_resp.__exit__ = MagicMock(return_value=False)
        return mock_resp

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_claude_key_valid(self, mock_urlopen: MagicMock) -> None:
        """Valid Claude key returns True."""
        mock_urlopen.return_value = self._make_mock_response(200)
        assert _test_provider_key("claude", "sk-ant-valid") is True

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_claude_key_invalid(self, mock_urlopen: MagicMock) -> None:
        """Invalid Claude key (401) returns False."""
        import urllib.error

        mock_urlopen.side_effect = urllib.error.HTTPError(
            url="", code=401, msg="Unauthorized", hdrs={}, fp=BytesIO(b"")
        )
        assert _test_provider_key("claude", "sk-ant-bad") is False

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_gemini_key_valid(self, mock_urlopen: MagicMock) -> None:
        """Valid Gemini key returns True."""
        mock_urlopen.return_value = self._make_mock_response(200)
        assert _test_provider_key("gemini", "AIza-valid-key") is True

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_openai_key_valid(self, mock_urlopen: MagicMock) -> None:
        """Valid OpenAI key returns True."""
        mock_urlopen.return_value = self._make_mock_response(200)
        assert _test_provider_key("gpt", "sk-valid-key") is True

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_ollama_check(self, mock_urlopen: MagicMock) -> None:
        """Ollama test hits localhost with no key."""
        mock_urlopen.return_value = self._make_mock_response(200)
        assert _test_provider_key("ollama", "") is True

    def test_unknown_provider(self) -> None:
        """Unknown provider returns False (no test endpoint configured)."""
        assert _test_provider_key("unknown_provider_xyz", "key") is False

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_network_error_returns_false(self, mock_urlopen: MagicMock) -> None:
        """Network errors return False, not raise."""
        mock_urlopen.side_effect = ConnectionError("no network")
        assert _test_provider_key("claude", "sk-ant-key") is False

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_test_key_via_vault(
        self, mock_urlopen: MagicMock, vault: KeyVault
    ) -> None:
        """test_key() integrates store + get + HTTP call."""
        mock_urlopen.return_value = self._make_mock_response(200)
        vault.store_key("claude", "sk-ant-api03-integration-test")
        assert vault.test_key("claude") is True

    def test_test_key_no_stored_key(self, vault: KeyVault) -> None:
        """test_key() returns False if no key is stored."""
        assert vault.test_key("claude") is False

    @patch("runtime.vault.key_vault.urllib.request.urlopen")
    def test_403_returns_false(self, mock_urlopen: MagicMock) -> None:
        """HTTP 403 (forbidden) returns False."""
        import urllib.error

        mock_urlopen.side_effect = urllib.error.HTTPError(
            url="", code=403, msg="Forbidden", hdrs={}, fp=BytesIO(b"")
        )
        assert _test_provider_key("gpt", "sk-bad-key") is False


# ── Encryption Rotation ─────────────────────────────────────────────────


class TestRotateEncryption:
    def test_rotate_no_passphrase_to_passphrase(self, vault: KeyVault) -> None:
        """Rotate from no passphrase to a passphrase."""
        vault.store_key("claude", "my-secret-key-abcdef")
        vault.store_key("gemini", "another-secret-key-xyz")

        count = vault.rotate_encryption(
            old_passphrase=None, new_passphrase="new-pass"
        )
        assert count == 2

        # Old (no passphrase) no longer works
        assert vault.get_key("claude") is None
        # New passphrase works
        assert vault.get_key("claude", passphrase="new-pass") == "my-secret-key-abcdef"
        assert (
            vault.get_key("gemini", passphrase="new-pass")
            == "another-secret-key-xyz"
        )

    def test_rotate_passphrase_to_none(self, vault: KeyVault) -> None:
        """Rotate from passphrase back to no passphrase."""
        vault.store_key("claude", "my-secret-key-abcdef", passphrase="old-pass")
        vault.rotate_encryption(old_passphrase="old-pass", new_passphrase=None)

        assert vault.get_key("claude") == "my-secret-key-abcdef"

    def test_rotate_wrong_old_passphrase_raises(self, vault: KeyVault) -> None:
        """Wrong old passphrase raises ValueError."""
        vault.store_key("claude", "my-secret-key-abcdef")
        with pytest.raises(ValueError, match="Failed to decrypt"):
            vault.rotate_encryption(old_passphrase="wrong", new_passphrase="new")

    def test_rotate_empty_vault(self, vault: KeyVault) -> None:
        """Rotating an empty vault returns 0."""
        assert vault.rotate_encryption() == 0


# ── Export and Import ────────────────────────────────────────────────────


class TestExportImport:
    def test_export_import_roundtrip(self, tmp_path: Path) -> None:
        """Export from one vault, import to another — all keys preserved."""
        vault1_path = tmp_path / "vault1.json"
        vault2_path = tmp_path / "vault2.json"
        vault1 = KeyVault(vault_path=vault1_path)
        vault2 = KeyVault(vault_path=vault2_path)

        vault1.store_key("claude", "claude-key-abc123def456")
        vault1.store_key("gemini", "gemini-key-xyz789qrs012")

        blob = vault1.export_encrypted("export-pass")
        count = vault2.import_encrypted(blob, "export-pass")

        assert count == 2
        assert vault2.get_key("claude") == "claude-key-abc123def456"
        assert vault2.get_key("gemini") == "gemini-key-xyz789qrs012"

    def test_export_is_not_plaintext(self, vault: KeyVault) -> None:
        """The exported blob must not contain raw keys."""
        vault.store_key("claude", "sk-ant-api03-SUPER-SECRET-KEY")
        blob = vault.export_encrypted("pass123")
        assert b"sk-ant-api03-SUPER-SECRET-KEY" not in blob

    def test_import_wrong_passphrase_raises(self, vault: KeyVault) -> None:
        """Wrong import passphrase raises ValueError."""
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        blob = vault.export_encrypted("correct-pass")

        vault2 = KeyVault(vault_path=vault._vault_path.parent / "vault2.json")
        with pytest.raises(ValueError, match="wrong passphrase"):
            vault2.import_encrypted(blob, "wrong-pass")

    def test_import_invalid_format_raises(self, vault: KeyVault) -> None:
        """Non-export data raises ValueError."""
        with pytest.raises(ValueError, match="not a Brynq vault export"):
            vault.import_encrypted(b"random garbage data", "pass")

    def test_export_empty_passphrase_raises(self, vault: KeyVault) -> None:
        """Empty export passphrase raises ValueError."""
        with pytest.raises(ValueError, match="cannot be empty"):
            vault.export_encrypted("")

    def test_import_empty_passphrase_raises(self, vault: KeyVault) -> None:
        """Empty import passphrase raises ValueError."""
        with pytest.raises(ValueError, match="cannot be empty"):
            vault.import_encrypted(b"anything", "")

    def test_export_import_cross_machine(self, tmp_path: Path) -> None:
        """Simulate export on machine A, import on machine B."""
        vault_a_path = tmp_path / "vault_a.json"
        vault_b_path = tmp_path / "vault_b.json"
        vault_a = KeyVault(vault_path=vault_a_path)

        vault_a.store_key("claude", "claude-key-portable-test")
        blob = vault_a.export_encrypted("transport-pass")

        # Simulate machine B with different machine ID
        fake_id = b"\xaa" * 32
        with patch("runtime.vault.key_vault._get_machine_id", return_value=fake_id):
            vault_b = KeyVault(vault_path=vault_b_path)
            count = vault_b.import_encrypted(blob, "transport-pass")
            assert count == 1
            assert vault_b.get_key("claude") == "claude-key-portable-test"

    def test_import_overwrites_existing(self, tmp_path: Path) -> None:
        """Importing a key for an existing provider overwrites it."""
        vault1_path = tmp_path / "vault1.json"
        vault2_path = tmp_path / "vault2.json"

        vault1 = KeyVault(vault_path=vault1_path)
        vault1.store_key("claude", "new-key-from-export")
        blob = vault1.export_encrypted("pass")

        vault2 = KeyVault(vault_path=vault2_path)
        vault2.store_key("claude", "old-key-on-machine")
        vault2.import_encrypted(blob, "pass")

        assert vault2.get_key("claude") == "new-key-from-export"


# ── File Permission Checks ──────────────────────────────────────────────


class TestFilePermissions:
    @pytest.mark.skipif(os.name == "nt", reason="Unix permissions only")
    def test_vault_file_is_0600(self, vault_with_path: tuple[KeyVault, Path]) -> None:
        """On Unix, vault file should have 0600 permissions."""
        vault, vault_file = vault_with_path
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")

        mode = vault_file.stat().st_mode & 0o777
        assert mode == 0o600, f"Expected 0600, got {oct(mode)}"

    def test_vault_creates_parent_dirs(self, tmp_path: Path) -> None:
        """Vault auto-creates parent directories if they don't exist."""
        vault_file = tmp_path / "deep" / "nested" / "vault.json"
        vault = KeyVault(vault_path=vault_file)
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        assert vault_file.exists()

    def test_corrupted_vault_recreated(self, tmp_path: Path) -> None:
        """A corrupted vault file is replaced with a fresh one."""
        vault_file = tmp_path / "vault.json"
        vault_file.parent.mkdir(parents=True, exist_ok=True)
        vault_file.write_text("not json at all {{{", encoding="utf-8")

        vault = KeyVault(vault_path=vault_file)
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        assert vault.get_key("claude") == "sk-ant-api03-ABCDEF1234567890"


# ── Vault Structure ─────────────────────────────────────────────────────


class TestVaultStructure:
    def test_vault_json_format(self, vault_with_path: tuple[KeyVault, Path]) -> None:
        """Vault file matches expected JSON schema."""
        vault, vault_file = vault_with_path
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")
        vault.store_key("gemini", "gemini-key-xyz-1234567890")

        data = json.loads(vault_file.read_text("utf-8"))

        assert data["version"] == 2
        assert isinstance(data["salt"], str)
        assert isinstance(data["keys"], dict)
        assert isinstance(data["metadata"], dict)
        assert "anthropic" in data["keys"]  # 'claude' alias resolved
        assert "google" in data["keys"]  # 'gemini' alias resolved
        assert "anthropic" in data["metadata"]
        assert "added_at" in data["metadata"]["anthropic"]
        assert "provider" in data["metadata"]["anthropic"]

    def test_last_used_updated_on_get(
        self, vault_with_path: tuple[KeyVault, Path]
    ) -> None:
        """get_key() updates last_used in metadata."""
        vault, vault_file = vault_with_path
        vault.store_key("claude", "sk-ant-api03-ABCDEF1234567890")

        # Read back — should set last_used
        vault.get_key("claude")

        data = json.loads(vault_file.read_text("utf-8"))
        assert "last_used" in data["metadata"]["anthropic"]  # alias resolved
