"""
BIBIVIDI — License key tests (TODO-28, TDD)

These tests are written BEFORE the implementation. They will all fail until
bvd/license.py, the `bibividi activate` CLI command, and the config.toml
reader are implemented.

Coverage:
  - Config: license_key read from ~/.bibividi/config.toml
  - `bibividi activate <key>`: validates against billing API, stores key
  - `bibividi activate <key>`: rejects invalid key (API returns 401)
  - `bibividi activate <key>`: handles billing API unreachable (network error)
  - `bibividi start` without a key: proxy starts but prints activation notice
  - License key hash: SHA-256 stored in executions, not the key itself
  - Billing calls: X-BIBIVIDI-License header sent when key is present
  - Stripe webhook: checkout.session.completed → key generated + email sent
  - Webhook: duplicate event (idempotency) does not generate a second key
  - Webhook: missing/invalid Stripe signature returns 400
"""
from __future__ import annotations

import hashlib
import json
from io import StringIO
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner
from rich.console import Console as RichConsole

from bvd.cli import main


# ── Helpers ────────────────────────────────────────────────────────────────────

def _make_console() -> tuple[RichConsole, StringIO]:
    buf = StringIO()
    con = RichConsole(file=buf, highlight=False, no_color=True, width=120)
    return con, buf


def _write_config(tmp_path: Path, key: str) -> Path:
    config_dir = tmp_path / ".bibividi"
    config_dir.mkdir(parents=True, exist_ok=True)
    config_file = config_dir / "config.toml"
    config_file.write_text(f'[auth]\nlicense_key = "{key}"\n')
    return config_file


# ── Config: reading license_key from config.toml ───────────────────────────────

def test_license_key_loaded_from_config_toml(tmp_path):
    """license_key is read from ~/.bibividi/config.toml [auth] section."""
    from bvd.license import load_license_key

    _write_config(tmp_path, "bvd-testkey-abc123")
    key = load_license_key(config_dir=tmp_path / ".bibividi")
    assert key == "bvd-testkey-abc123"


def test_license_key_returns_none_when_config_missing(tmp_path):
    """Returns None when ~/.bibividi/config.toml does not exist."""
    from bvd.license import load_license_key

    key = load_license_key(config_dir=tmp_path / ".bibividi")
    assert key is None


def test_license_key_returns_none_when_section_missing(tmp_path):
    """Returns None when config.toml has no [auth] section."""
    from bvd.license import load_license_key

    config_dir = tmp_path / ".bibividi"
    config_dir.mkdir()
    (config_dir / "config.toml").write_text("[proxy]\nport = 8080\n")
    key = load_license_key(config_dir=config_dir)
    assert key is None


# ── `bibividi activate` — happy path ──────────────────────────────────────────

def test_activate_stores_key_in_config_toml(tmp_path):
    """`bibividi activate <key>` writes the key to ~/.bibividi/config.toml."""
    from bvd.license import load_license_key

    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            return_value=True,
        ),
    ):
        result = runner.invoke(main, ["activate", "bvd-valid-key-001"])

    assert result.exit_code == 0
    key = load_license_key(config_dir=tmp_path / ".bibividi")
    assert key == "bvd-valid-key-001"


def test_activate_prints_confirmation(tmp_path):
    """`bibividi activate <key>` prints a success message on stdout."""
    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            return_value=True,
        ),
    ):
        runner.invoke(main, ["activate", "bvd-valid-key-001"])

    output = buf.getvalue()
    assert "activated" in output.lower() or "success" in output.lower()


# ── `bibividi activate` — invalid key ─────────────────────────────────────────

def test_activate_invalid_key_exits_nonzero(tmp_path):
    """`bibividi activate <bad-key>` exits with non-zero when API returns 401."""
    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            return_value=False,
        ),
    ):
        result = runner.invoke(main, ["activate", "bvd-bad-key"])

    assert result.exit_code != 0


def test_activate_invalid_key_does_not_write_config(tmp_path):
    """An invalid key must not be persisted to config.toml."""
    from bvd.license import load_license_key

    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            return_value=False,
        ),
    ):
        runner.invoke(main, ["activate", "bvd-bad-key"])

    assert load_license_key(config_dir=tmp_path / ".bibividi") is None


def test_activate_invalid_key_prints_error(tmp_path):
    """`bibividi activate <bad-key>` prints a human-readable error."""
    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            return_value=False,
        ),
    ):
        runner.invoke(main, ["activate", "bvd-bad-key"])

    output = buf.getvalue()
    assert "invalid" in output.lower() or "error" in output.lower()


# ── `bibividi activate` — network error ───────────────────────────────────────

def test_activate_network_error_exits_nonzero(tmp_path):
    """`bibividi activate` exits non-zero when the billing API is unreachable."""
    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            side_effect=OSError("Network unreachable"),
        ),
    ):
        result = runner.invoke(main, ["activate", "bvd-any-key"])

    assert result.exit_code != 0


def test_activate_network_error_does_not_write_config(tmp_path):
    """Key must not be persisted when the billing API is unreachable."""
    from bvd.license import load_license_key

    runner = CliRunner()
    con, buf = _make_console()

    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.BIBIVIDI_DATA_DIR", tmp_path / ".bibividi"),
        patch(
            "bvd.license.validate_key_with_api",
            new_callable=AsyncMock,
            side_effect=OSError("Network unreachable"),
        ),
    ):
        runner.invoke(main, ["activate", "bvd-any-key"])

    assert load_license_key(config_dir=tmp_path / ".bibividi") is None


# ── `bibividi start` without a key ────────────────────────────────────────────

def test_start_without_key_prints_activation_notice(tmp_path):
    """`bibividi start` without a license key prints a one-time notice."""
    runner = CliRunner()
    con, buf = _make_console()

    # Simulate no config.toml → no license key
    with (
        patch("bvd.cli.console", con),
        patch("bvd.license.load_license_key", return_value=None),
        # Prevent the real proxy from starting
        patch("bvd.cli._run_proxy", new_callable=MagicMock),
    ):
        runner.invoke(main, ["start"])

    output = buf.getvalue()
    assert "bibividi activate" in output.lower() or "license" in output.lower()


def test_start_without_key_proxy_still_starts(tmp_path):
    """Proxy starts normally even without a license key (billing disabled)."""
    runner = CliRunner()
    mock_run = MagicMock()

    with (
        patch("bvd.license.load_license_key", return_value=None),
        patch("bvd.cli._run_proxy", mock_run),
    ):
        runner.invoke(main, ["start"])

    mock_run.assert_called_once()


# ── License key hash in executions ────────────────────────────────────────────

def test_license_key_hash_is_sha256():
    """The hash stored in executions is SHA-256 of the raw key, not the key."""
    from bvd.license import hash_license_key

    key = "bvd-mykey-abc123"
    expected = hashlib.sha256(key.encode()).hexdigest()
    assert hash_license_key(key) == expected


def test_license_key_hash_different_keys_differ():
    from bvd.license import hash_license_key

    assert hash_license_key("bvd-key-001") != hash_license_key("bvd-key-002")


@pytest.mark.asyncio
async def test_execution_stores_license_key_hash(tmp_path):
    """record_execution() stores license_key_hash when a key is active."""
    import aiosqlite
    from unittest.mock import patch as p

    from bvd.license import hash_license_key

    db = tmp_path / "test.db"

    with p("bvd.config.settings.db_path", db):
        from bvd.engine.library import Library
        lib = Library()
        await lib.init()
        await lib.close()

        from bvd.engine.stats import record_execution
        await record_execution(
            script_id="test-skill",
            tokens_before=1000,
            tokens_after=100,
            latency_ms=50.0,
            model_name="claude-haiku-4-5-20251001",
            license_key="bvd-testkey-abc123",
        )

        async with aiosqlite.connect(db) as conn:
            async with conn.execute(
                "SELECT license_key_hash FROM executions LIMIT 1"
            ) as cur:
                row = await cur.fetchone()

    assert row is not None
    assert row[0] == hash_license_key("bvd-testkey-abc123")


@pytest.mark.asyncio
async def test_execution_without_key_stores_null(tmp_path):
    """record_execution() without a key stores NULL in license_key_hash."""
    import aiosqlite
    from unittest.mock import patch as p

    db = tmp_path / "test.db"

    with p("bvd.config.settings.db_path", db):
        from bvd.engine.library import Library
        lib = Library()
        await lib.init()
        await lib.close()

        from bvd.engine.stats import record_execution
        await record_execution(
            script_id="test-skill",
            tokens_before=1000,
            tokens_after=100,
            latency_ms=50.0,
            model_name="claude-haiku-4-5-20251001",
            license_key=None,
        )

        async with aiosqlite.connect(db) as conn:
            async with conn.execute(
                "SELECT license_key_hash FROM executions LIMIT 1"
            ) as cur:
                row = await cur.fetchone()

    assert row is not None
    assert row[0] is None


# ── Billing API calls include X-BIBIVIDI-License header ───────────────────────

@pytest.mark.asyncio
async def test_billing_call_sends_license_header(tmp_path):
    """monthly_billing() sends X-BIBIVIDI-License header when key is set."""
    import httpx

    captured_headers: dict = {}

    async def fake_post(url, *, headers=None, json=None, **kw):
        captured_headers.update(headers or {})
        return MagicMock(status_code=200, json=lambda: {})

    with (
        patch("bvd.license.load_license_key", return_value="bvd-live-key-xyz"),
        patch("bvd.billing._billing_http_post", new=fake_post),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
    ):
        from bvd.engine.library import Library
        lib = Library()
        await lib.init()
        await lib.close()

        from bvd.billing import monthly_billing
        await monthly_billing(2026, 3, "pro")

    assert captured_headers.get("X-BIBIVIDI-License") == "bvd-live-key-xyz"


@pytest.mark.asyncio
async def test_billing_call_omits_header_without_key(tmp_path):
    """monthly_billing() does not send X-BIBIVIDI-License when no key."""
    captured_headers: dict = {}

    async def fake_post(url, *, headers=None, json=None, **kw):
        captured_headers.update(headers or {})
        return MagicMock(status_code=200, json=lambda: {})

    with (
        patch("bvd.license.load_license_key", return_value=None),
        patch("bvd.billing._billing_http_post", new=fake_post),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
    ):
        from bvd.engine.library import Library
        lib = Library()
        await lib.init()
        await lib.close()

        from bvd.billing import monthly_billing
        await monthly_billing(2026, 3, "pro")

    assert "X-BIBIVIDI-License" not in captured_headers


# ── Stripe webhook: checkout.session.completed ────────────────────────────────

@pytest.mark.asyncio
async def test_webhook_generates_key_on_checkout_completed():
    """checkout.session.completed generates a license key for the customer."""
    from bvd.billing import handle_stripe_webhook

    event = {
        "type": "checkout.session.completed",
        "data": {
            "object": {
                "id": "cs_test_abc",
                "customer": "cus_stripe123",
                "customer_email": "user@example.com",
            }
        },
    }

    mock_send_email = AsyncMock()
    mock_save_key = AsyncMock()

    with (
        patch("bvd.billing.save_license_key", mock_save_key),
        patch("bvd.billing.send_activation_email", mock_send_email),
    ):
        await handle_stripe_webhook(event, stripe_signature="valid")

    mock_save_key.assert_called_once()
    saved_key: str = mock_save_key.call_args[0][0]
    assert saved_key.startswith("bvd-")

    mock_send_email.assert_called_once()
    email_kwargs = mock_send_email.call_args[1]
    assert email_kwargs["to"] == "user@example.com"
    assert saved_key in email_kwargs["body"]


@pytest.mark.asyncio
async def test_webhook_idempotent_on_duplicate_event():
    """Duplicate checkout.session.completed does not generate a second key."""
    from bvd.billing import handle_stripe_webhook

    event = {
        "type": "checkout.session.completed",
        "data": {
            "object": {
                "id": "cs_test_duplicate",
                "customer": "cus_stripe456",
                "customer_email": "dup@example.com",
            }
        },
    }

    mock_save_key = AsyncMock()
    mock_send_email = AsyncMock()

    with (
        patch("bvd.billing.save_license_key", mock_save_key),
        patch("bvd.billing.send_activation_email", mock_send_email),
        # Second call: key already exists for this session_id
        patch("bvd.billing.key_exists_for_session", return_value=True),
    ):
        await handle_stripe_webhook(event, stripe_signature="valid")

    mock_save_key.assert_not_called()
    mock_send_email.assert_not_called()


@pytest.mark.asyncio
async def test_webhook_ignores_unrelated_event_types():
    """Events other than checkout.session.completed are silently ignored."""
    from bvd.billing import handle_stripe_webhook

    event = {
        "type": "payment_intent.succeeded",
        "data": {"object": {"id": "pi_test_xyz"}},
    }

    mock_save_key = AsyncMock()

    with patch("bvd.billing.save_license_key", mock_save_key):
        await handle_stripe_webhook(event, stripe_signature="valid")

    mock_save_key.assert_not_called()


@pytest.mark.asyncio
async def test_webhook_invalid_signature_raises():
    """Invalid Stripe signature raises an error (prevents spoofed webhooks)."""
    from bvd.billing import handle_stripe_webhook, WebhookSignatureError

    event = {
        "type": "checkout.session.completed",
        "data": {"object": {"id": "cs_fake", "customer": "cus_fake", "customer_email": "x@x.com"}},
    }

    with patch("bvd.billing.verify_stripe_signature", side_effect=WebhookSignatureError("bad sig")):
        with pytest.raises(WebhookSignatureError):
            await handle_stripe_webhook(event, stripe_signature="bad-sig")


# ── Schema migration: license_key_hash column ─────────────────────────────────

@pytest.mark.asyncio
async def test_schema_has_license_key_hash_column(tmp_path):
    """executions table has license_key_hash column after migration."""
    import aiosqlite
    from unittest.mock import patch as p

    db = tmp_path / "test.db"
    with p("bvd.config.settings.db_path", db):
        from bvd.engine.library import Library
        lib = Library()
        await lib.init()
        await lib.close()

    async with aiosqlite.connect(db) as conn:
        async with conn.execute("PRAGMA table_info(executions)") as cur:
            cols = {row[1] for row in await cur.fetchall()}

    assert "license_key_hash" in cols
