"""
Integration tests — full proxy pipeline with mock upstream.

The BIBIVIDI proxy is called via ASGI transport.
The upstream AI API is simulated by mock_api.py (also via ASGI transport).
"""
from __future__ import annotations

import json
import sys
from pathlib import Path
from unittest.mock import AsyncMock, patch

import pytest
from httpx import ASGITransport, AsyncClient

# mock_api.py lives at the repo root; make it importable
sys.path.insert(0, str(Path(__file__).parent.parent))
from bvd.core.proxy import app
from bvd.engine.library import Script
from bvd.engine.matcher import MatchResult
from bvd.engine.runner import RunResult
from mock_api import app as mock_app  # noqa: E402

# ── Fixtures ──────────────────────────────────────────────────────────────────

_SAMPLE_SCRIPT = Script(
    id="integ-test-condenser",
    name="Integration Test Condenser",
    description="Condenses prompts for integration testing",
    code='print("[CONDENSED]")',
    tags=["test"],
)
_SAMPLE_MATCH = MatchResult(
    script=_SAMPLE_SCRIPT, script_id=_SAMPLE_SCRIPT.id, score=0.97
)
_SAMPLE_RUN = RunResult(
    output="[CONDENSED: prompt processed locally]",
    stdout="[CONDENSED: prompt processed locally]",
    stderr="",
    exit_code=0,
    timed_out=False,
)


@pytest.fixture
def mock_upstream():
    """httpx.AsyncClient backed by mock_api ASGI — simulates Anthropic API."""
    return AsyncClient(
        transport=ASGITransport(app=mock_app),
        base_url="https://api.anthropic.com",
    )


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

def _ai_request(prompt: str, streaming: bool = False) -> dict:
    return {
        "model": "claude-haiku-4-5-20251001",
        "max_tokens": 50,
        "stream": streaming,
        "messages": [{"role": "user", "content": prompt}],
    }


# ── Tests ─────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_passthrough_non_ai_host(mock_upstream, tmp_path):
    """Requests to non-AI hosts are blocked with 403 (SSRF guard)."""
    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_upstream)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            # Host: github.com is not in target_apis → blocked by SSRF guard
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "github.com"},
                content=json.dumps(_ai_request("hello")).encode(),
            )

    assert resp.status_code == 403


@pytest.mark.asyncio
async def test_no_match_passthrough(mock_upstream, tmp_path):
    """AI-host request with no matching script passes through to upstream."""
    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.config.settings.generator_enabled", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_upstream)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=None)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps(_ai_request("Tell me about the weather.")).encode(),
            )

    assert resp.status_code == 200
    body = resp.json()
    # mock_api echoes back; response should be from mock upstream
    assert "mock" in body["content"][0]["text"].lower()


@pytest.mark.asyncio
async def test_match_found_injects_condensed_content(mock_upstream, tmp_path):
    """When a script matches, the condensed output replaces the user message."""
    captured_body: dict = {}

    async def fake_request(method, url, **kwargs):
        # Capture what the proxy actually sends upstream
        captured_body.update(json.loads(kwargs.get("content", b"{}")))
        # Return a minimal mock response
        import httpx
        return httpx.Response(
            200,
            json={
                "id": "msg_test",
                "type": "message",
                "role": "assistant",
                "model": "claude-haiku-4-5-20251001",
                "content": [{"type": "text", "text": "OK"}],
                "stop_reason": "end_turn",
                "usage": {"input_tokens": 10, "output_tokens": 2},
            },
        )

    mock_client = AsyncMock()
    mock_client.request = fake_request

    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_client)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=_SAMPLE_MATCH)),
        patch("bvd.core.proxy.run_script", AsyncMock(return_value=_SAMPLE_RUN)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps(
                    _ai_request("Analyse ce contrat et résume les clauses importantes.")
                ).encode(),
            )

    assert resp.status_code == 200
    # The last user message should contain the condensed output, not the original prompt
    user_msg = captured_body["messages"][-1]["content"]
    assert "[CONDENSED: prompt processed locally]" in user_msg
    assert "[BIBIVIDI: pre-processed]" in user_msg


@pytest.mark.asyncio
async def test_streaming_passthrough(mock_upstream, tmp_path):
    """Streaming request is proxied as SSE chunks."""
    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.config.settings.generator_enabled", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_upstream)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=None)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps(_ai_request("Hello!", streaming=True)).encode(),
            )

    assert resp.status_code == 200
    # SSE body should contain at least a message_start event
    text = resp.text
    assert "message_start" in text
    assert "content_block_delta" in text


# ── BSS Ed25519 signature verification ────────────────────────────────────────

def _make_ed25519_keypair():
    """Return (private_key, pubkey_field, sign_fn) for test use."""
    import base64

    from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

    priv = Ed25519PrivateKey.generate()
    pub_bytes = priv.public_key().public_bytes_raw()
    pubkey_field = "ed25519:" + base64.b64encode(pub_bytes).decode()

    def sign(data: bytes) -> bytes:
        return priv.sign(data)

    return pubkey_field, sign


@pytest.mark.asyncio
async def test_import_bss_valid_signature(tmp_path):
    """import_bss accepts a skill pack with a valid Ed25519 signature."""
    from bvd.engine.library import Library

    code = 'print("signed")\n'
    pubkey_field, sign = _make_ed25519_keypair()
    sig = sign(code.encode())
    meta = {"id": "sig-ok", "name": "Sig OK", "description": "Test", "pubkey": pubkey_field}

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "sig_ok.db"):
        await lib.init()
        try:
            script = await lib.import_bss(meta, code, skill_sig=sig)
        finally:
            await lib.close()

    assert script.id == "sig-ok"


@pytest.mark.asyncio
async def test_import_bss_bad_signature_raises(tmp_path):
    """import_bss rejects a skill pack with a tampered skill.py."""
    from bvd.engine.library import Library

    code = 'print("signed")\n'
    pubkey_field, sign = _make_ed25519_keypair()
    sig = sign(b"some other content")   # signature of different data
    meta = {"id": "sig-bad", "name": "Sig Bad", "description": "Test", "pubkey": pubkey_field}

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "sig_bad.db"):
        await lib.init()
        try:
            with pytest.raises(ValueError, match="Ed25519 signature"):
                await lib.import_bss(meta, code, skill_sig=sig)
        finally:
            await lib.close()


@pytest.mark.asyncio
async def test_import_bss_no_sig_skips_verification(tmp_path):
    """import_bss with pubkey but no skill_sig skips signature check (backwards compat)."""
    from bvd.engine.library import Library

    code = 'print("unsigned")\n'
    pubkey_field, _ = _make_ed25519_keypair()
    meta = {
        "id": "sig-skip",
        "name": "Sig Skip",
        "description": "Test",
        "pubkey": pubkey_field,
    }

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "sig_skip.db"):
        await lib.init()
        try:
            script = await lib.import_bss(meta, code)   # no skill_sig
        finally:
            await lib.close()

    assert script.id == "sig-skip"


# ── BSS hash verification ─────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_import_bss_valid_hash(tmp_path):
    """import_bss accepts a skill pack when the hash matches."""
    import hashlib

    from bvd.engine.library import Library

    code = 'print("hello")\n'
    meta = {
        "id": "hash-ok",
        "name": "Hash OK",
        "description": "Test",
        "hash": hashlib.sha256(code.encode()).hexdigest(),
    }

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "lib.db"):
        await lib.init()
        try:
            script = await lib.import_bss(meta, code)
        finally:
            await lib.close()

    assert script.id == "hash-ok"


@pytest.mark.asyncio
async def test_import_bss_bad_hash_raises(tmp_path):
    """import_bss rejects a skill pack when the hash does not match."""
    from bvd.engine.library import Library

    code = 'print("hello")\n'
    meta = {
        "id": "hash-bad",
        "name": "Hash Bad",
        "description": "Test",
        "hash": "deadbeef" * 8,  # wrong hash
    }

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "lib.db"):
        await lib.init()
        try:
            with pytest.raises(ValueError, match="hash mismatch"):
                await lib.import_bss(meta, code)
        finally:
            await lib.close()


@pytest.mark.asyncio
async def test_import_bss_no_hash_skips_check(tmp_path):
    """import_bss is permissive when skill.json has no hash field (legacy packs)."""
    from bvd.engine.library import Library

    code = 'print("hello")\n'
    meta = {"id": "no-hash", "name": "No Hash", "description": "Test"}

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "lib.db"):
        await lib.init()
        try:
            script = await lib.import_bss(meta, code)
        finally:
            await lib.close()

    assert script.id == "no-hash"


@pytest.mark.asyncio
async def test_pending_skill_not_executed_by_proxy(mock_upstream, tmp_path):
    """A matched skill with status='pending' must pass through without executing."""
    from bvd.engine.library import Script
    from bvd.engine.matcher import MatchResult

    pending_script = Script(
        id="pending-skill",
        name="Pending",
        description="Not yet approved",
        code="print('should not run')",
        status="pending",
    )
    mock_match = MatchResult(script=pending_script, script_id="pending-skill", score=0.95)

    with (
        patch("bvd.config.settings.target_apis", ["api.anthropic.com"]),
        patch("bvd.config.settings.db_path", tmp_path / "test.db"),
        patch("bvd.config.settings.track_savings", False),
        patch("bvd.config.settings.generator_enabled", False),
        patch("bvd.core.proxy.get_client", AsyncMock(return_value=mock_upstream)),
        patch("bvd.core.proxy.matcher.match", AsyncMock(return_value=mock_match)),
    ):
        async with AsyncClient(
            transport=ASGITransport(app=app), base_url="http://test"
        ) as client:
            resp = await client.post(
                "/v1/messages",
                headers={"Host": "api.anthropic.com"},
                content=json.dumps(_ai_request("Extract clauses from contract.")).encode(),
            )

    # Proxy must forward the original unmodified request to upstream
    assert resp.status_code == 200
    body = resp.json()
    assert "mock" in body["content"][0]["text"].lower()


@pytest.mark.asyncio
async def test_migration_fresh_install(tmp_path):
    """Fresh DB: after init(), schema_version = CURRENT_SCHEMA_VERSION."""
    from bvd.engine.library import CURRENT_SCHEMA_VERSION, Library

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "fresh.db"):
        await lib.init()
        try:
            async with lib.db.execute("SELECT version FROM schema_version") as cur:
                row = await cur.fetchone()
        finally:
            await lib.close()

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


@pytest.mark.asyncio
async def test_migration_from_v1(tmp_path):
    """Existing v1 DB (scripts table without status): init() adds the status column."""
    import aiosqlite

    db_path = tmp_path / "v1.db"

    # Simulate a v1 database: scripts table without status column, no schema_version table
    async with aiosqlite.connect(db_path) as db:
        await db.executescript("""
            CREATE TABLE scripts (
                id          TEXT PRIMARY KEY,
                name        TEXT NOT NULL,
                description TEXT NOT NULL,
                version     TEXT NOT NULL DEFAULT '1.0.0',
                author      TEXT,
                tags        TEXT,
                code        TEXT NOT NULL,
                embedding   BLOB,
                created_at  TEXT NOT NULL,
                updated_at  TEXT NOT NULL,
                usage_count INTEGER NOT NULL DEFAULT 0,
                avg_saving  REAL NOT NULL DEFAULT 0.0
            );
            CREATE TABLE executions (
                id              INTEGER PRIMARY KEY AUTOINCREMENT,
                script_id       TEXT NOT NULL REFERENCES scripts(id),
                tokens_before   INTEGER NOT NULL,
                tokens_after    INTEGER NOT NULL,
                latency_ms      REAL,
                executed_at     TEXT NOT NULL
            );
        """)
        await db.commit()

    from bvd.engine.library import CURRENT_SCHEMA_VERSION, Library

    lib = Library()
    with patch("bvd.config.settings.db_path", db_path):
        await lib.init()
        try:
            # Verify status column exists by querying table_info
            async with lib.db.execute("PRAGMA table_info(scripts)") as cur:
                cols = [r[1] for r in await cur.fetchall()]
            async with lib.db.execute("SELECT version FROM schema_version") as cur:
                version_row = await cur.fetchone()
        finally:
            await lib.close()

    assert "status" in cols
    assert version_row is not None
    assert version_row[0] == CURRENT_SCHEMA_VERSION


@pytest.mark.asyncio
async def test_migration_idempotent(tmp_path):
    """Running init() twice on the same DB raises no errors and version stays correct."""
    from bvd.engine.library import CURRENT_SCHEMA_VERSION, Library

    with patch("bvd.config.settings.db_path", tmp_path / "idem.db"):
        lib1 = Library()
        await lib1.init()
        await lib1.close()

        lib2 = Library()
        await lib2.init()
        try:
            async with lib2.db.execute("SELECT version FROM schema_version") as cur:
                row = await cur.fetchone()
        finally:
            await lib2.close()

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


@pytest.mark.asyncio
async def test_approve_activates_pending_skill(tmp_path):
    """approve() flips status from pending → active."""
    from bvd.engine.library import Library

    code = 'print("hello")\n'
    meta = {"id": "approve-me", "name": "Approve Me", "description": "Test"}

    lib = Library()
    with patch("bvd.config.settings.db_path", tmp_path / "lib.db"):
        await lib.init()
        try:
            script = await lib.import_bss(meta, code, status="pending")
            assert script.status == "pending"

            pending = await lib.list_pending()
            assert any(s.id == "approve-me" for s in pending)

            ok = await lib.approve("approve-me")
            assert ok is True

            approved = await lib.get("approve-me")
            assert approved is not None
            assert approved.status == "active"

            pending_after = await lib.list_pending()
            assert not any(s.id == "approve-me" for s in pending_after)
        finally:
            await lib.close()
