"""Tests for Chrome cookie extraction on macOS."""

import hashlib
import os
import sqlite3
import shutil
import subprocess
import tempfile
from pathlib import Path
from unittest import mock

import pytest

OPENSSL_AVAILABLE = shutil.which("openssl") is not None

from lib.chrome_cookies import (
    CHROME_COOKIES_DB,
    CHROME_IV_HEX,
    CHROME_KEY_LENGTH,
    CHROME_PBKDF2_ITERATIONS,
    CHROME_SALT,
    _derive_aes_key,
    _get_chrome_encryption_key,
    _get_db_version,
    _remove_pkcs7_padding,
    _extract_chromium_cookies_macos,
    _decrypt_v10_value,
    extract_chrome_cookies_macos,
)

# ---------------------------------------------------------------------------
# Helpers — create real encrypted cookie values using known key + system openssl
# ---------------------------------------------------------------------------

KNOWN_PASSPHRASE = b"test_passphrase_for_unit_tests"
KNOWN_AES_KEY = _derive_aes_key(KNOWN_PASSPHRASE)


def _encrypt_value_v10(plaintext: str, aes_key: bytes) -> bytes:
    """Encrypt a value the same way Chrome v10 does, using system openssl.

    Returns b'v10' + AES-128-CBC ciphertext with PKCS7 padding.
    """
    hex_key = aes_key.hex()
    result = subprocess.run(
        [
            "openssl", "enc", "-aes-128-cbc", "-e",
            "-K", hex_key,
            "-iv", CHROME_IV_HEX,
        ],
        input=plaintext.encode("utf-8"),
        capture_output=True,
        timeout=5,
    )
    assert result.returncode == 0, f"openssl encrypt failed: {result.stderr}"
    return b"v10" + result.stdout


def _encrypt_value_v10_with_sha_prefix(plaintext: str, aes_key: bytes) -> bytes:
    """Encrypt with a 32-byte SHA-256 prefix (Chrome 130+ style)."""
    raw = b"\x00" * 32 + plaintext.encode("utf-8")
    hex_key = aes_key.hex()
    result = subprocess.run(
        [
            "openssl", "enc", "-aes-128-cbc", "-e",
            "-K", hex_key,
            "-iv", CHROME_IV_HEX,
        ],
        input=raw,
        capture_output=True,
        timeout=5,
    )
    assert result.returncode == 0, f"openssl encrypt failed: {result.stderr}"
    return b"v10" + result.stdout


def _create_chrome_cookies_db(path: str, cookies: list[tuple], db_version: int = 20) -> None:
    """Create a minimal Chrome Cookies SQLite database.

    cookies: list of (host_key, name, value, encrypted_value) tuples
    """
    conn = sqlite3.connect(path)
    c = conn.cursor()
    c.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
    c.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('version', ?)", (str(db_version),))
    c.execute(
        "CREATE TABLE IF NOT EXISTS cookies ("
        "  host_key TEXT NOT NULL,"
        "  name TEXT NOT NULL,"
        "  value TEXT NOT NULL DEFAULT '',"
        "  encrypted_value BLOB NOT NULL DEFAULT x'',"
        "  path TEXT NOT NULL DEFAULT '/',"
        "  expires_utc INTEGER NOT NULL DEFAULT 0,"
        "  is_secure INTEGER NOT NULL DEFAULT 1,"
        "  is_httponly INTEGER NOT NULL DEFAULT 1,"
        "  creation_utc INTEGER NOT NULL DEFAULT 0,"
        "  last_access_utc INTEGER NOT NULL DEFAULT 0,"
        "  has_expires INTEGER NOT NULL DEFAULT 1,"
        "  is_persistent INTEGER NOT NULL DEFAULT 1,"
        "  priority INTEGER NOT NULL DEFAULT 1,"
        "  samesite INTEGER NOT NULL DEFAULT 0,"
        "  source_scheme INTEGER NOT NULL DEFAULT 2,"
        "  source_port INTEGER NOT NULL DEFAULT 443,"
        "  last_update_utc INTEGER NOT NULL DEFAULT 0"
        ")"
    )
    for host_key, name, value, encrypted_value in cookies:
        c.execute(
            "INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?)",
            (host_key, name, value, encrypted_value),
        )
    conn.commit()
    conn.close()

# ---------------------------------------------------------------------------
# PKCS7 padding tests
# ---------------------------------------------------------------------------


class TestPkcs7Padding:
    def test_valid_padding_1(self):
        # 1 byte of padding
        data = b"hello world!!!!!" + b"\x01"
        assert _remove_pkcs7_padding(data) == b"hello world!!!!!"

    def test_valid_padding_5(self):
        data = b"hello world" + b"\x05\x05\x05\x05\x05"
        assert _remove_pkcs7_padding(data) == b"hello world"

    def test_valid_padding_16(self):
        # Full block of padding
        data = b"\x10" * 16
        assert _remove_pkcs7_padding(data) == b""

    def test_invalid_padding_zero(self):
        data = b"hello\x00"
        assert _remove_pkcs7_padding(data) is None

    def test_invalid_padding_mismatch(self):
        data = b"hello\x03\x03\x02"
        assert _remove_pkcs7_padding(data) is None

    def test_empty_data(self):
        assert _remove_pkcs7_padding(b"") is None

# ---------------------------------------------------------------------------
# Key derivation test
# ---------------------------------------------------------------------------


class TestKeyDerivation:
    def test_derive_aes_key_deterministic(self):
        key1 = _derive_aes_key(b"my_passphrase")
        key2 = _derive_aes_key(b"my_passphrase")
        assert key1 == key2
        assert len(key1) == 16

    def test_derive_aes_key_different_passphrases(self):
        key1 = _derive_aes_key(b"passphrase_a")
        key2 = _derive_aes_key(b"passphrase_b")
        assert key1 != key2

# ---------------------------------------------------------------------------
# Decryption test (real openssl, known key)
# ---------------------------------------------------------------------------


@pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
class TestDecryption:
    def test_decrypt_v10_roundtrip(self):
        """Encrypt then decrypt — verifies the full pipeline works."""
        original = "my_secret_cookie_value_12345"
        encrypted = _encrypt_value_v10(original, KNOWN_AES_KEY)
        assert encrypted[:3] == b"v10"

        decrypted = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=20)
        assert decrypted == original

    def test_decrypt_v10_chrome130_with_sha_prefix(self):
        """Chrome 130+ (db_version >= 24) strips 32-byte SHA-256 prefix."""
        original = "session_token_abc"
        encrypted = _encrypt_value_v10_with_sha_prefix(original, KNOWN_AES_KEY)

        decrypted = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=24)
        assert decrypted == original

    def test_decrypt_wrong_key_returns_none_or_garbage(self):
        """Wrong key should either fail decryption or produce garbage."""
        original = "secret"
        encrypted = _encrypt_value_v10(original, KNOWN_AES_KEY)
        wrong_key = _derive_aes_key(b"wrong_passphrase")

        result = _decrypt_v10_value(encrypted, wrong_key, db_version=20)
        # Either None (padding check fails) or garbage (not the original)
        assert result is None or result != original

    def test_decrypt_empty_ciphertext(self):
        """v10 prefix with no ciphertext should return None."""
        assert _decrypt_v10_value(b"v10", KNOWN_AES_KEY, db_version=20) is None

# ---------------------------------------------------------------------------
# Chrome not installed → returns None
# ---------------------------------------------------------------------------


class TestChromeNotInstalled:
    def test_db_not_found(self):
        with mock.patch(
            "lib.chrome_cookies._find_chromium_cookies_db",
            return_value=None,
        ):
            result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
            assert result is None

# ---------------------------------------------------------------------------
# Keychain access denied → returns None
# ---------------------------------------------------------------------------


class TestKeychainDenied:
    def test_security_command_fails(self):
        with mock.patch("lib.chrome_cookies.subprocess.run") as mock_run:
            mock_run.return_value = subprocess.CompletedProcess(
                args=[], returncode=44, stdout="", stderr="security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain."
            )
            result = _get_chrome_encryption_key()
            assert result is None

    def test_security_command_not_found(self):
        with mock.patch("lib.chrome_cookies.subprocess.run", side_effect=FileNotFoundError):
            result = _get_chrome_encryption_key()
            assert result is None

# ---------------------------------------------------------------------------
# openssl not found → returns None
# ---------------------------------------------------------------------------


@pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
class TestOpensslNotFound:
    def test_openssl_missing(self):
        encrypted = _encrypt_value_v10("test", KNOWN_AES_KEY)
        with mock.patch("lib.chrome_cookies.subprocess.run", side_effect=FileNotFoundError):
            result = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=20)
            assert result is None

# ---------------------------------------------------------------------------
# Unencrypted cookie values → returned as-is
# ---------------------------------------------------------------------------


class TestUnencryptedCookies:
    @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits are not reliable on Windows")
    def test_temp_cookie_db_copy_is_owner_only(self, tmp_path):
        """Copied Chromium cookie DB temp files are chmodded owner-only before read."""
        db_path = tmp_path / "Cookies"
        _create_chrome_cookies_db(str(db_path), [
            (".x.com", "auth_token", "plain_token_value", b""),
        ])
        os.chmod(db_path, 0o644)

        real_connect = sqlite3.connect

        def assert_temp_copy_locked(path, *args, **kwargs):
            if Path(str(path)) != db_path:
                assert Path(str(path)).stat().st_mode & 0o777 == 0o600
            return real_connect(path, *args, **kwargs)

        with mock.patch("lib.chrome_cookies.sqlite3.connect", side_effect=assert_temp_copy_locked):
            result = _extract_chromium_cookies_macos(
                db_path,
                "Chrome Safe Storage",
                ".x.com",
                ["auth_token"],
            )

        assert result == {"auth_token": "plain_token_value"}

    def test_plain_value_returned(self, tmp_path):
        """Unencrypted cookies (value column populated) returned without decryption."""
        db_path = str(tmp_path / "Cookies")
        _create_chrome_cookies_db(db_path, [
            (".x.com", "auth_token", "plain_token_value", b""),
            (".x.com", "ct0", "plain_ct0_value", b""),
        ])

        with mock.patch("lib.chrome_cookies._find_chromium_cookies_db", return_value=Path(db_path)):
            # No keychain needed for unencrypted values
            with mock.patch("lib.chrome_cookies._get_chromium_encryption_key", return_value=None):
                result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])

        assert result == {"auth_token": "plain_token_value", "ct0": "plain_ct0_value"}

# ---------------------------------------------------------------------------
# Full integration: mock DB with real v10 encryption, mock Keychain
# ---------------------------------------------------------------------------


class TestFullExtraction:
    @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
    def test_encrypted_cookies_extracted(self, tmp_path):
        """End-to-end: create DB with real v10-encrypted values, extract them."""
        auth_val = "my_auth_token_123"
        ct0_val = "my_ct0_csrf_456"

        encrypted_auth = _encrypt_value_v10(auth_val, KNOWN_AES_KEY)
        encrypted_ct0 = _encrypt_value_v10(ct0_val, KNOWN_AES_KEY)

        db_path = str(tmp_path / "Cookies")
        _create_chrome_cookies_db(db_path, [
            (".x.com", "auth_token", "", encrypted_auth),
            (".x.com", "ct0", "", encrypted_ct0),
            (".other.com", "other", "", b""),  # unrelated cookie
        ])

        with mock.patch("lib.chrome_cookies._find_chromium_cookies_db", return_value=Path(db_path)):
            with mock.patch(
                "lib.chrome_cookies._get_chromium_encryption_key",
                return_value=KNOWN_PASSPHRASE,
            ):
                result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])

        assert result is not None
        assert result["auth_token"] == auth_val
        assert result["ct0"] == ct0_val

    def test_no_matching_cookies_returns_none(self, tmp_path):
        db_path = str(tmp_path / "Cookies")
        _create_chrome_cookies_db(db_path, [
            (".other.com", "session", "val", b""),
        ])

        with mock.patch("lib.chrome_cookies._find_chromium_cookies_db", return_value=Path(db_path)):
            with mock.patch("lib.chrome_cookies._get_chromium_encryption_key", return_value=None):
                result = extract_chrome_cookies_macos(".x.com", ["auth_token"])

        assert result is None

    @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
    def test_chrome130_db_version_24(self, tmp_path):
        """Chrome 130+ with db_version >= 24 strips SHA-256 prefix."""
        auth_val = "token_for_chrome130"
        encrypted_auth = _encrypt_value_v10_with_sha_prefix(auth_val, KNOWN_AES_KEY)

        db_path = str(tmp_path / "Cookies")
        _create_chrome_cookies_db(db_path, [
            (".x.com", "auth_token", "", encrypted_auth),
        ], db_version=24)

        with mock.patch("lib.chrome_cookies._find_chromium_cookies_db", return_value=Path(db_path)):
            with mock.patch(
                "lib.chrome_cookies._get_chromium_encryption_key",
                return_value=KNOWN_PASSPHRASE,
            ):
                result = extract_chrome_cookies_macos(".x.com", ["auth_token"])

        assert result is not None
        assert result["auth_token"] == auth_val

# ---------------------------------------------------------------------------
# DB version detection
# ---------------------------------------------------------------------------


class TestDbVersion:
    def test_reads_version_from_meta(self, tmp_path):
        db_path = str(tmp_path / "test.db")
        conn = sqlite3.connect(db_path)
        c = conn.cursor()
        c.execute("CREATE TABLE meta (key TEXT, value TEXT)")
        c.execute("INSERT INTO meta VALUES ('version', '24')")
        conn.commit()
        assert _get_db_version(c) == 24
        conn.close()

    def test_no_meta_table_returns_zero(self, tmp_path):
        db_path = str(tmp_path / "test.db")
        conn = sqlite3.connect(db_path)
        c = conn.cursor()
        c.execute("CREATE TABLE dummy (x TEXT)")
        conn.commit()
        assert _get_db_version(c) == 0
        conn.close()
