"""
Integration tests for `deployment_mode` three-tier flag.

Spec: ``docs/requirements-change-personal-desktop.md`` rev.2 §10.2.
Decisions: §13.2 Q8-Q12 (mode chip / 404 oracle / no migration tool /
tenant_id naming / lab bind default).

The single most important property we verify here is the **Q10 smoke
test**: a tenant + agent + API key created in `desktop` mode keep
working unchanged after switching to `lab` and `paas`. If this breaks,
the rev.2 "no migration tool needed" decision falls apart and we lose
the whole "200 LOC vs touching 29 files" engineering value prop.

Coverage:
  - config: AGENTPAAS_DEPLOYMENT_MODE validation + default
  - middleware: /admin /billing return 404 in desktop, 401/200 in lab/paas
  - other routes still reachable in desktop (auth dep unchanged)
  - auto_bootstrap_desktop: idempotent + tenant_id format (Q11) +
    contextual `tenants.name` alias (hostname/user)
  - Q10: full mode-transition smoke (desktop → lab → paas), same data
"""
from __future__ import annotations

import os
os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")

import json
import secrets

import pytest

try:
    from fastapi.testclient import TestClient
except ImportError:
    pytest.skip(
        "fastapi.testclient requires httpx — `pip install -e ./agentpaas/[dev]`",
        allow_module_level=True,
    )

from agentpaas.api.app import app
from agentpaas.api.middleware.auth import hash_key
from agentpaas.config import settings
from agentpaas.db.models import Database, gen_id, now_utc
import agentpaas.db.session as _session_mod


# ─────────────────────────────────────────────────────────────────────────────
# Fixtures (shared shape with test_api_endpoints.py / test_authenticate.py)
# ─────────────────────────────────────────────────────────────────────────────

@pytest.fixture
def db():
    prev = _session_mod._db
    _session_mod._db = Database("sqlite:///:memory:")
    try:
        yield _session_mod._db
    finally:
        _session_mod._db = prev


@pytest.fixture
def mode():
    """Restore deployment_mode after each test (it's a module singleton)."""
    original = settings.deployment_mode
    yield  # tests mutate settings.deployment_mode in-body
    settings.deployment_mode = original


@pytest.fixture
def client(db, mode):
    with TestClient(app) as c:
        yield c


def _bootstrap(db):
    """Quick helper: insert tenant + user + admin api_key. Returns (tid, raw)."""
    tid = gen_id("tn_")
    uid = gen_id("usr_")
    raw_key = f"ap_{secrets.token_hex(16)}"
    now = now_utc()
    db.execute(
        "INSERT INTO tenants (id, name, plan, status, created_at) "
        "VALUES (?, ?, 'free', 'active', ?)",
        (tid, "test-tenant", now),
    )
    db.execute(
        "INSERT INTO users (id, tenant_id, email, role, created_at) "
        "VALUES (?, ?, '', 'admin', ?)",
        (uid, tid, now),
    )
    db.execute(
        "INSERT INTO api_keys "
        "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
        " rate_limit, status, created_at) "
        "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
        (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
         json.dumps(["agents:*", "admin:*", "billing:read"]), now),
    )
    db.commit()
    return tid, raw_key


def _auth(key: str) -> dict:
    return {"Authorization": f"Bearer {key}"}


# ─────────────────────────────────────────────────────────────────────────────
# config.py — deployment_mode parsing
# ─────────────────────────────────────────────────────────────────────────────

def test_config_default_mode_is_desktop():
    """No env var → desktop (CR rev.2: installer-first default)."""
    # We test via settings directly since it's already constructed.
    # The full env-var roundtrip is covered by test_config_invalid_mode.
    assert settings.deployment_mode in ("desktop", "lab", "paas")
    # When this test runs unprefixed, the default applies.
    if "AGENTPAAS_DEPLOYMENT_MODE" not in os.environ:
        assert settings.deployment_mode == "desktop"


def test_config_invalid_mode_raises():
    """Garbage env var → ValueError at construction time (fail fast)."""
    os.environ["AGENTPAAS_DEPLOYMENT_MODE"] = "not-a-mode"
    try:
        from agentpaas.config import AgentPaaSConfig
        with pytest.raises(ValueError) as exc:
            AgentPaaSConfig()
        assert "deployment_mode" in str(exc.value).lower() or "desktop|lab|paas" in str(exc.value)
    finally:
        os.environ.pop("AGENTPAAS_DEPLOYMENT_MODE", None)


def test_config_desktop_picks_visible_data_dir(tmp_path, monkeypatch):
    """CR FR-002: desktop users need a discoverable, branded data dir
    (`~/LambdAgentDesktop`), not a hidden `~/.agentpaas/data`. lab/paas
    keep the hidden dir to follow OS convention."""
    monkeypatch.delenv("AGENTPAAS_DATA_DIR", raising=False)
    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
    monkeypatch.setenv("HOME", str(tmp_path))
    from agentpaas.config import AgentPaaSConfig
    cfg = AgentPaaSConfig()
    assert cfg.deployment_mode == "desktop"
    assert cfg.data_dir.endswith("LambdAgentDesktop"), cfg.data_dir

    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab")
    cfg2 = AgentPaaSConfig()
    assert cfg2.deployment_mode == "lab"
    assert cfg2.data_dir.endswith(".agentpaas/data"), cfg2.data_dir


def test_config_default_host_is_loopback_for_non_paas(monkeypatch, tmp_path):
    """Q12 + audit critical #1 same posture: desktop / lab default to
    127.0.0.1 (locked down). paas defaults to 0.0.0.0 (behind reverse
    proxy in production). AGENTPAAS_HOST overrides in all cases."""
    monkeypatch.delenv("AGENTPAAS_HOST", raising=False)
    monkeypatch.setenv("HOME", str(tmp_path))
    from agentpaas.config import AgentPaaSConfig

    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
    assert AgentPaaSConfig().host == "127.0.0.1"

    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "lab")
    assert AgentPaaSConfig().host == "127.0.0.1"

    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "paas")
    assert AgentPaaSConfig().host == "0.0.0.0"

    # Explicit override wins in any mode (advanced user case).
    monkeypatch.setenv("AGENTPAAS_HOST", "0.0.0.0")
    monkeypatch.setenv("AGENTPAAS_DEPLOYMENT_MODE", "desktop")
    assert AgentPaaSConfig().host == "0.0.0.0"


# ─────────────────────────────────────────────────────────────────────────────
# Middleware: desktop blocks /admin /billing → 404 (Q9 oracle defense)
# ─────────────────────────────────────────────────────────────────────────────

def test_desktop_mode_blocks_admin_routes(client, db):
    """In desktop mode, /api/v1/admin/* must return 404. Audit-style oracle
    defense (Q9): a remote scanner cannot tell whether the deployment is
    desktop / lab / paas because the response is identical to "no such
    route". Critically: 404 means NOT 401/403 (which would leak that
    the route exists but requires auth).

    We use POST /admin/tenants (a real, gate-eligible endpoint) so that
    in `lab` mode the same probe returns 401 from the auth dependency —
    that distinction is what `test_lab_mode_does_not_block_admin` below
    relies on."""
    settings.deployment_mode = "desktop"
    _, key = _bootstrap(db)

    # Even with a valid Bearer the route is unreachable in desktop.
    r = client.post("/api/v1/admin/tenants", json={}, headers=_auth(key))
    assert r.status_code == 404, (
        f"desktop should hide /admin completely; got {r.status_code}: "
        f"{r.text[:200]}"
    )
    body = r.json()
    # The detail string must be `Not Found` (our explicit gate), not
    # FastAPI's default which has a different shape — that's how we
    # verify our middleware actually fired.
    assert body.get("detail") == "Not Found"


def test_desktop_mode_blocks_billing_routes(client, db):
    settings.deployment_mode = "desktop"
    _, key = _bootstrap(db)
    r = client.get("/api/v1/billing/usage", headers=_auth(key))
    assert r.status_code == 404


def test_desktop_mode_allows_agents_route(client, db):
    """Sanity: non-blocked routes still respond. Desktop mode must not
    accidentally gate the routes the user actually needs."""
    settings.deployment_mode = "desktop"
    _, key = _bootstrap(db)
    r = client.get("/api/v1/agents", headers=_auth(key))
    # 200 (list, possibly empty) — NOT 404.
    assert r.status_code == 200, r.text[:200]


def test_desktop_mode_allows_knowledge_route(client, db):
    settings.deployment_mode = "desktop"
    _, key = _bootstrap(db)
    r = client.get("/api/v1/knowledge", headers=_auth(key))
    assert r.status_code == 200


# ─────────────────────────────────────────────────────────────────────────────
# Lab / PaaS modes expose admin routes (audit fixes #14/#28/#30 still active)
# ─────────────────────────────────────────────────────────────────────────────

def test_lab_mode_does_not_block_admin(client, db):
    """In lab mode the route is reachable. Whether it returns 401/200
    depends on the existing auth middleware — but it MUST NOT be 404
    from our deployment_mode_gate."""
    settings.deployment_mode = "lab"
    r = client.get("/api/v1/admin/tenants")  # no auth — likely 401
    # Anything except 404 confirms the gate did NOT fire.
    assert r.status_code != 404, (
        f"lab mode should NOT block /admin; got 404 from {r.text[:200]}"
    )


def test_paas_mode_does_not_block_billing(client, db):
    settings.deployment_mode = "paas"
    r = client.get("/api/v1/billing/usage")
    assert r.status_code != 404


# ─────────────────────────────────────────────────────────────────────────────
# /api/v1/setup/mode — Q8 chip endpoint (unauthenticated)
# ─────────────────────────────────────────────────────────────────────────────

def test_setup_mode_endpoint_no_auth_required(client, db):
    """Q8: the mode chip renders BEFORE login (so the first-run wizard
    can adapt). /setup/mode must be reachable without a Bearer."""
    r = client.get("/api/v1/setup/mode")
    assert r.status_code == 200, r.text[:200]
    body = r.json()
    assert "mode" in body and body["mode"] in ("desktop", "lab", "paas")
    assert "tenant_alias" in body
    assert "bootstrapped" in body


def test_setup_mode_reflects_current_deployment_mode(client, db, mode):
    """Same client, different settings.deployment_mode → endpoint reports
    the current value (no caching)."""
    settings.deployment_mode = "paas"
    assert client.get("/api/v1/setup/mode").json()["mode"] == "paas"

    settings.deployment_mode = "lab"
    assert client.get("/api/v1/setup/mode").json()["mode"] == "lab"

    settings.deployment_mode = "desktop"
    assert client.get("/api/v1/setup/mode").json()["mode"] == "desktop"


def test_setup_mode_reports_bootstrapped_false_on_fresh_install(client, db, tmp_path):
    """Fresh DB + no config file → bootstrapped=false. The first-run
    wizard depends on this signal to know whether to render itself.

    We have to patch CONFIG_FILE because the developer's real
    ~/.agentpaas/config.json may already contain an api_key from a real
    install; without the patch this test would assert against that
    file's contents."""
    from agentpaas.api.v1 import setup as setup_mod
    original = setup_mod.CONFIG_FILE
    setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
    try:
        r = client.get("/api/v1/setup/mode")
        body = r.json()
        assert body["bootstrapped"] is False
        assert body["tenant_alias"] == ""
    finally:
        setup_mod.CONFIG_FILE = original


# ─────────────────────────────────────────────────────────────────────────────
# auto_bootstrap_desktop — Q11 tenant_id naming + idempotency
# ─────────────────────────────────────────────────────────────────────────────

def test_auto_bootstrap_creates_anonymous_tenant_id(db, mode, tmp_path):
    """Q11: tenant_id is anonymous `tn_local_{hex}`; `tenants.name`
    carries the contextual hostname/user alias."""
    settings.deployment_mode = "desktop"

    # Redirect the config file to a temp path so the test doesn't litter
    # ~/.agentpaas/config.json.
    from agentpaas.api.v1 import setup as setup_mod
    original_cfg = setup_mod.CONFIG_FILE
    setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
    try:
        from agentpaas.api.v1.setup import auto_bootstrap_desktop
        raw = auto_bootstrap_desktop()
        assert raw is not None, "expected fresh bootstrap to return a key"
        assert raw.startswith("ap_")

        # Verify tenant_id format
        rows = db.fetchall("SELECT id, name FROM tenants")
        assert len(rows) == 1
        assert rows[0]["id"].startswith("tn_local_"), rows[0]["id"]
        # Q11: contextual alias is set
        assert "/" in rows[0]["name"] or rows[0]["name"] == "local"
    finally:
        setup_mod.CONFIG_FILE = original_cfg


def test_auto_bootstrap_is_idempotent(db, mode, tmp_path):
    """Calling twice yields a tenant the second time too (no duplicate),
    and returns None on the second call (= already configured)."""
    settings.deployment_mode = "desktop"

    from agentpaas.api.v1 import setup as setup_mod
    original_cfg = setup_mod.CONFIG_FILE
    setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
    try:
        from agentpaas.api.v1.setup import auto_bootstrap_desktop
        first = auto_bootstrap_desktop()
        second = auto_bootstrap_desktop()
        assert first is not None
        assert second is None, "second call should be a no-op"

        # Exactly one tenant — bootstrap did not create a duplicate.
        rows = db.fetchall("SELECT id FROM tenants")
        assert len(rows) == 1
    finally:
        setup_mod.CONFIG_FILE = original_cfg


# ─────────────────────────────────────────────────────────────────────────────
# FR-001 — desktop one-click login (/setup/desktop-key)
# ─────────────────────────────────────────────────────────────────────────────

def test_desktop_key_404_in_lab_mode(client, db, mode):
    """The one-click-login key endpoint must not exist outside desktop —
    lab/paas use the explicit SetupWizard paste flow. 404 hides it."""
    settings.deployment_mode = "lab"
    r = client.get("/api/v1/setup/desktop-key")
    assert r.status_code == 404


def test_desktop_key_loopback_only(client, db, mode):
    """In desktop mode the endpoint is loopback-gated; TestClient's
    synthetic host is non-loopback → 404 (the secure default we rely on
    everywhere). The real desktop browser hits it from 127.0.0.1."""
    settings.deployment_mode = "desktop"
    r = client.get("/api/v1/setup/desktop-key")
    # Non-loopback caller → 404 from _require_loopback.
    assert r.status_code == 404


# ─────────────────────────────────────────────────────────────────────────────
# FR-002 — data-dir get/set (first-run wizard)
# ─────────────────────────────────────────────────────────────────────────────

def test_get_data_dir_reports_current(client, db):
    """GET /setup/data-dir returns the live data dir + subdirs + mode.
    TestClient's host is loopback-equivalent for our _require_loopback
    check (starlette uses 'testclient' which... is NOT loopback). So this
    actually exercises the loopback guard returning 404 from a non-local
    caller — which is the secure default. We assert that explicitly."""
    r = client.get("/api/v1/setup/data-dir")
    # TestClient host is 'testclient' (non-loopback) → guard fires.
    assert r.status_code == 404


def test_set_data_dir_persists_and_requires_restart(tmp_path, monkeypatch):
    """PUT /setup/data-dir writes the choice to config.json and reports
    change_requires_restart. We call the handler logic via the module
    function path with CONFIG_FILE patched, since the HTTP layer is
    loopback-gated (covered above)."""
    from agentpaas.api.v1 import setup as setup_mod

    original = setup_mod.CONFIG_FILE
    setup_mod.CONFIG_FILE = str(tmp_path / "config.json")
    try:
        target = tmp_path / "MyResearchData"
        cfg = setup_mod._load_config()
        assert "data_dir" not in cfg

        # Simulate the PUT body-handling: validate + persist.
        cfg["data_dir"] = str(target)
        setup_mod._save_config(cfg)

        # config.py must now pick it up.
        import importlib
        import agentpaas.config as config_mod
        # Point config's _CONFIG_JSON at our temp file too.
        monkeypatch.setattr(config_mod, "_CONFIG_JSON", str(tmp_path / "config.json"))
        assert config_mod._data_dir_from_config_json() == str(target)
        # And it wins over the mode default.
        assert config_mod._default_data_dir("desktop") == str(target)
    finally:
        setup_mod.CONFIG_FILE = original


# ─────────────────────────────────────────────────────────────────────────────
# Q10 — mode-transition smoke test
# ─────────────────────────────────────────────────────────────────────────────

def test_mode_transition_desktop_to_lab_to_paas(client, db, mode):
    """Q10 headline assertion: a tenant + API key + agent created in
    desktop mode still work after the operator flips to lab and then
    paas. The whole rev.2 strategy depends on this — if data doesn't
    carry across mode changes, we'd need a real migration tool and the
    "200 LOC" engineering claim falls apart.

    We don't restart the test app; the mode flag is module-level and we
    flip it mid-test. That's functionally equivalent to a process
    restart because (a) no in-memory state encodes the mode and (b)
    every request reads settings.deployment_mode fresh."""
    # ── In desktop mode, bootstrap a tenant + admin key ──
    settings.deployment_mode = "desktop"
    tid, key = _bootstrap(db)

    # Create an agent via the public API (verifies the auth + scoping path).
    # `config` is required by CreateAgentRequest; minimal react skeleton.
    r = client.post(
        "/api/v1/agents",
        json={
            "name": "transition-test",
            "description": "Q10 smoke",
            "config": {
                "type": "react",
                "name": "transition-test",
                "systemPrompt": "Q10 mode-transition smoke test",
                "model": {"provider": "stub", "name": "stub"},
            },
        },
        headers=_auth(key),
    )
    assert r.status_code in (200, 201), r.text[:200]
    agent = r.json()
    aid = agent.get("id") or agent.get("agent_id")
    assert aid

    # ── Flip to lab. Same key must continue to work; admin route now reachable. ──
    settings.deployment_mode = "lab"
    r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
    assert r.status_code == 200, r.text[:200]

    # In lab, the previously-blocked /admin is reachable. Without auth
    # we get 401 (route exists, auth missing) — not 404.
    r = client.post("/api/v1/admin/tenants", json={})
    assert r.status_code == 401, (
        f"lab: expected 401 from auth, got {r.status_code}: {r.text[:120]}"
    )

    # ── Flip to paas. Same data, all routes still expose. ──
    settings.deployment_mode = "paas"
    r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
    assert r.status_code == 200

    # ── Flip back to desktop. Admin route is hidden again, but the
    # bootstrapped agent is still listable. ──
    settings.deployment_mode = "desktop"
    r = client.get(f"/api/v1/agents/{aid}", headers=_auth(key))
    assert r.status_code == 200

    r = client.post("/api/v1/admin/tenants", json={}, headers=_auth(key))
    assert r.status_code == 404  # gate fires again
