from __future__ import annotations

import inspect
from collections.abc import Generator
from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy import create_engine, select, update
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetType
from app.db.base import Base
from app.models.agent_asset import AgentAsset, AgentAssetTestRun
from app.models.agent_asset_release_telemetry import AgentAssetReleaseObservation
from app.services.agent_asset_release_monitor import AgentAssetReleaseMonitor
from app.services.agent_asset_release_review import AgentAssetReleaseReviewService
from app.services.agent_asset_release_telemetry import (
    AgentAssetReleaseTelemetryService,
    ReleaseObservationInput,
)


@pytest.fixture
def db() -> Generator[Session, None, None]:
    engine = create_engine(
        "sqlite+pysqlite:///:memory:",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    Base.metadata.create_all(engine)
    factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
    with factory() as session:
        yield session
    engine.dispose()


def _policy(
    *,
    min_precision: float = 0.8,
    max_error_rate: float = 0.25,
) -> dict:
    return {
        "shadow_min_samples": 1,
        "canary_min_samples": 1,
        "max_error_rate": max_error_rate,
        "min_precision": min_precision,
        "max_precision_drop": 0.2,
        "canary_traffic_percent": 10,
    }


def _seed_asset(
    db: Session,
    *,
    asset_id: str = "release-monitor-asset",
    tenant_id: str | None = "tenant-a",
    stage: str = "shadow",
    candidate_version: str = "v2",
    min_precision: float = 0.8,
    max_error_rate: float = 0.25,
    detail_mode: str = "json_risk",
) -> AgentAsset:
    previous_config = {
        "detail_mode": detail_mode,
        "enabled": True,
        "stable_marker": f"stable:{asset_id}",
    }
    if tenant_id is not None:
        previous_config["tenant_id"] = tenant_id
    state = {
        "release_id": f"release-{asset_id}",
        "stage": stage,
        "candidate_version": candidate_version,
        "previous_version": "v1",
        "policy": _policy(
            min_precision=min_precision,
            max_error_rate=max_error_rate,
        ),
        "previous_config": dict(previous_config),
        "history": [],
    }
    asset = AgentAsset(
        id=asset_id,
        tenant_id=tenant_id or "platform",
        scope="tenant" if tenant_id is not None else "platform",
        asset_type=AgentAssetType.RULE.value,
        code=f"risk.{asset_id}",
        name="发布监控规则",
        description="",
        domain=AgentAssetDomain.EXPENSE.value,
        scenario_json=["travel"],
        owner="finance",
        status=AgentAssetStatus.ACTIVE.value,
        current_version="v1",
        published_version="v1",
        working_version="v2",
        config_json={
            **previous_config,
            "release_guard": state,
            # 外部或客户端放入配置的数字不是发布评测输入。
            "untrusted_external_metrics": {
                "total": 999999,
                "precision": 1.0,
            },
        },
    )
    db.add(asset)
    db.commit()
    return asset


def _record(
    db: Session,
    asset: AgentAsset,
    *,
    source: str,
    candidate_hit: bool,
    baseline_hit: bool | None = True,
    label: str | None = None,
    failed: bool = False,
) -> str:
    state = dict((asset.config_json or {})["release_guard"])
    service = AgentAssetReleaseTelemetryService(db)
    observation = service.record_observation(
        ReleaseObservationInput(
            tenant_id=str((asset.config_json or {}).get("tenant_id") or "tenant-a"),
            asset_id=asset.id,
            release_id=str(state["release_id"]),
            stage=str(state["stage"]),  # type: ignore[arg-type]
            version=str(state["candidate_version"]),
            rule_code=asset.code,
            source_key=source,
            candidate_hit=candidate_hit,
            baseline_hit=baseline_hit,
            runtime_status="failed" if failed else "completed",
            failure_code="evaluator_error" if failed else "none",
        )
    )
    if label is not None:
        service.record_review_label(
            tenant_id=observation.tenant_id,
            observation_id=observation.id,
            label=label,  # type: ignore[arg-type]
            request_id=f"review:{source}",
            actor_id="trusted-auditor",
        )
    db.commit()
    return observation.id


class _NeverCalledGuard:
    def __init__(self) -> None:
        self.calls = 0

    def record_evaluation(self, *_args, **_kwargs):
        self.calls += 1
        raise AssertionError("collecting telemetry must never call Release Guard")


def test_collecting_never_calls_guard_or_rolls_back(db: Session) -> None:
    asset = _seed_asset(db)
    _record(
        db,
        asset,
        source="claim-unlabeled",
        candidate_hit=True,
        label=None,
    )
    guard = _NeverCalledGuard()
    monitor = AgentAssetReleaseMonitor(db, guard_service=guard)  # type: ignore[arg-type]

    result = monitor.evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )

    refreshed = db.get(AgentAsset, asset.id)
    assert result["status"] == "collecting"
    assert result["telemetry_status"] == "collecting"
    assert result["evaluation_submitted"] is False
    assert result["metrics"]["candidate_pending_label_count"] == 1
    assert result["metrics"]["precision"] is None
    assert result["rolled_back"] is False
    assert guard.calls == 0
    assert refreshed.config_json["release_guard"]["stage"] == "shadow"
    assert db.scalar(select(AgentAssetTestRun)) is None


def test_ready_metrics_are_evaluated_and_passed(db: Session) -> None:
    asset = _seed_asset(db)
    _record(
        db,
        asset,
        source="claim-confirmed",
        candidate_hit=True,
        label="confirmed",
    )

    result = AgentAssetReleaseMonitor(db).evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )
    run = db.scalar(select(AgentAssetTestRun).where(AgentAssetTestRun.asset_id == asset.id))

    assert result["telemetry_status"] == "ready"
    assert result["status"] == "passed"
    assert result["evaluation_submitted"] is True
    assert result["rolled_back"] is False
    assert result["metrics"]["observed_count"] == 1
    assert result["metrics"]["precision"] == 1.0
    assert result["metrics"]["baseline_precision"] == 1.0
    assert result["metrics"]["recall"] == 1.0
    assert result["metrics"]["recall_lower_bound"] == 1.0
    assert run is not None
    assert run.input_json["total"] == 1
    assert run.input_json["failure_count"] == 0
    assert run.input_json["precision"] == 1.0
    assert run.result_json["details"]["metric_source"] == "release_runtime_telemetry"


def test_same_ready_snapshot_reuses_test_run(db: Session) -> None:
    asset = _seed_asset(db)
    _record(
        db,
        asset,
        source="claim-idempotent",
        candidate_hit=True,
        label="confirmed",
    )
    monitor = AgentAssetReleaseMonitor(db)

    first = monitor.evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )
    second = monitor.evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor-retry",
    )
    runs = list(
        db.scalars(
            select(AgentAssetTestRun).where(AgentAssetTestRun.asset_id == asset.id)
        ).all()
    )

    assert second == first
    assert len(runs) == 1


def test_real_low_precision_automatically_rolls_back(db: Session) -> None:
    asset = _seed_asset(db, min_precision=0.9, max_error_rate=1.0)
    _record(db, asset, source="claim-true", candidate_hit=True, label="confirmed")
    _record(
        db,
        asset,
        source="claim-false-positive",
        candidate_hit=True,
        label="false_positive",
    )

    result = AgentAssetReleaseMonitor(db).evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )
    refreshed = db.get(AgentAsset, asset.id)

    assert result["status"] == "failed"
    assert result["rolled_back"] is True
    assert result["release_stage"] == "rolled_back"
    assert result["metrics"]["precision"] == 0.5
    assert "precision_below_threshold" in result["reasons"]
    assert refreshed.published_version == "v1"
    assert refreshed.config_json["stable_marker"] == f"stable:{asset.id}"
    assert refreshed.config_json["release_guard"]["rollback"]["automatic"] is True


def test_real_runtime_failure_automatically_rolls_back(db: Session) -> None:
    asset = _seed_asset(db, min_precision=0.5, max_error_rate=0.1)
    _record(db, asset, source="claim-good", candidate_hit=True, label="confirmed")
    _record(
        db,
        asset,
        source="claim-evaluator-failed",
        candidate_hit=False,
        baseline_hit=None,
        failed=True,
    )

    result = AgentAssetReleaseMonitor(db).evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )

    assert result["status"] == "failed"
    assert result["rolled_back"] is True
    assert result["metrics"]["observed_count"] == 2
    assert result["metrics"]["runtime_failure_count"] == 1
    assert result["metrics"]["runtime_failure_rate"] == 0.5
    assert "error_rate_exceeded" in result["reasons"]
    assert {item["code"] for item in result["alerts"]} >= {
        "runtime_failures_detected",
        "release_auto_rolled_back",
    }


def test_all_runtime_failures_without_hits_automatically_roll_back(db: Session) -> None:
    asset = _seed_asset(db, min_precision=0.5, max_error_rate=0.1)
    _record(
        db,
        asset,
        source="claim-only-evaluator-failed",
        candidate_hit=False,
        baseline_hit=None,
        failed=True,
    )

    result = AgentAssetReleaseMonitor(db).evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )

    assert result["telemetry_status"] == "ready"
    assert result["status"] == "failed"
    assert result["rolled_back"] is True
    assert result["metrics"]["candidate_hit_count"] == 0
    assert result["metrics"]["precision"] is None
    assert result["metrics"]["runtime_failure_count"] == 1
    assert "error_rate_exceeded" in result["reasons"]


def test_incomplete_labels_with_tolerated_failure_cannot_pass(db: Session) -> None:
    asset = _seed_asset(db, min_precision=0.5, max_error_rate=1.0)
    _record(
        db,
        asset,
        source="claim-unlabeled-hit",
        candidate_hit=True,
        label=None,
    )
    _record(
        db,
        asset,
        source="claim-tolerated-evaluator-failed",
        candidate_hit=False,
        baseline_hit=None,
        failed=True,
    )

    result = AgentAssetReleaseMonitor(db).evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )

    assert result["telemetry_status"] == "ready"
    assert result["status"] == "collecting"
    assert result["rolled_back"] is False
    assert result["metrics"]["candidate_pending_label_count"] == 1
    assert "precision_metric_missing" in result["reasons"]
    assert "release_labels_pending" in {item["code"] for item in result["alerts"]}


def test_cross_tenant_and_global_asset_scope_are_enforced(db: Session) -> None:
    tenant_asset = _seed_asset(db)
    _record(db, tenant_asset, source="tenant-a-claim", candidate_hit=True, label="confirmed")
    monitor = AgentAssetReleaseMonitor(db)

    with pytest.raises(LookupError):
        monitor.evaluate_current(
            tenant_id="tenant-b",
            asset_id=tenant_asset.id,
            actor="foreign-monitor",
        )
    assert monitor.batch_evaluate(tenant_id="tenant-b", actor="foreign-monitor")["scanned"] == 0

    global_asset = _seed_asset(
        db,
        asset_id="global-release-monitor",
        tenant_id=None,
    )
    with pytest.raises(LookupError):
        _record(db, global_asset, source="global-claim", candidate_hit=True, label="confirmed")
    with pytest.raises(LookupError):
        monitor.evaluate_current(
            tenant_id="tenant-a",
            asset_id=global_asset.id,
            actor="tenant-monitor",
        )
    with pytest.raises(ValueError, match="cross-tenant aggregate"):
        monitor.evaluate_current(
            tenant_id="tenant-a",
            asset_id=global_asset.id,
            actor="platform-monitor",
            allow_global_management=True,
        )
    assert global_asset.config_json["release_guard"]["stage"] == "shadow"


def test_batch_scan_is_bounded_tenant_safe_and_failure_isolated(db: Session) -> None:
    collecting_asset = _seed_asset(db, asset_id="batch-collecting")
    _record(db, collecting_asset, source="collecting", candidate_hit=True)

    broken_asset = _seed_asset(
        db,
        asset_id="batch-error",
        candidate_version="",
    )
    passed_asset = _seed_asset(db, asset_id="batch-passed")
    _record(db, passed_asset, source="passed", candidate_hit=True, label="confirmed")
    rolled_asset = _seed_asset(
        db,
        asset_id="batch-rolled-back",
        min_precision=0.9,
        max_error_rate=1.0,
    )
    _record(db, rolled_asset, source="roll-true", candidate_hit=True, label="confirmed")
    _record(
        db,
        rolled_asset,
        source="roll-false",
        candidate_hit=True,
        label="false_positive",
    )
    foreign_asset = _seed_asset(db, asset_id="foreign-ready", tenant_id="tenant-b")
    _record(
        db,
        foreign_asset,
        source="foreign",
        candidate_hit=True,
        label="confirmed",
    )
    _seed_asset(db, asset_id="not-json", detail_mode="markdown")
    _seed_asset(db, asset_id="not-current", stage="rolled_back")
    monitor = AgentAssetReleaseMonitor(db)

    result = monitor.batch_evaluate(
        tenant_id="tenant-a",
        actor="batch-monitor",
        limit=10,
    )

    assert result["scanned"] == 4
    assert result["evaluated"] == 2
    assert result["collecting"] == 1
    assert result["rolled_back"] == 1
    assert len(result["errors"]) == 1
    assert result["errors"][0]["asset_id"] == broken_asset.id
    assert result["errors"][0]["alerts"][0]["code"] == "release_aggregation_failed"
    assert {item["asset_id"] for item in result["results"]} == {
        collecting_asset.id,
        passed_asset.id,
        rolled_asset.id,
    }
    assert foreign_asset.id not in str(result)
    assert db.get(AgentAsset, passed_asset.id).config_json["release_guard"]["stage"] == "shadow"
    assert db.get(AgentAsset, rolled_asset.id).config_json["release_guard"]["stage"] == (
        "rolled_back"
    )
    with pytest.raises(ValueError, match="between 1 and 500"):
        monitor.batch_evaluate(tenant_id="tenant-a", actor="batch-monitor", limit=501)


def test_overdue_review_sample_emits_actionable_alert(db: Session) -> None:
    asset = _seed_asset(db)
    observation_id = _record(
        db,
        asset,
        source="claim-overdue-review",
        candidate_hit=True,
    )
    # SQLite 测试库没有 PostgreSQL append-only 触发器；用 Core 语句只调整
    # fixture 时间，避免把“运营超时”测试误写成 ORM 可更新契约。
    db.execute(
        update(AgentAssetReleaseObservation)
        .where(AgentAssetReleaseObservation.id == observation_id)
        .values(created_at=datetime.now(UTC) - timedelta(hours=25))
    )
    db.commit()

    result = AgentAssetReleaseReviewService(db).list_pending(
        tenant_id="tenant-a",
        asset_id=asset.id,
    )

    assert result["telemetry_status"] == "collecting"
    assert result["metrics"]["candidate_oldest_pending_age_seconds"] >= 86_400
    assert "release_labels_overdue" in {item["code"] for item in result["alerts"]}


def test_monitor_contract_cannot_accept_fabricated_quality_metrics(db: Session) -> None:
    asset = _seed_asset(db, min_precision=0.9, max_error_rate=1.0)
    _record(
        db,
        asset,
        source="actual-false-positive",
        candidate_hit=True,
        label="false_positive",
    )
    signature = inspect.signature(AgentAssetReleaseMonitor.evaluate_current)

    assert {"total", "failure_count", "precision", "baseline_precision"}.isdisjoint(
        signature.parameters
    )
    with pytest.raises(TypeError):
        AgentAssetReleaseMonitor(db).evaluate_current(
            tenant_id="tenant-a",
            asset_id=asset.id,
            actor="malicious-monitor",
            precision=1.0,  # type: ignore[call-arg]
        )

    result = AgentAssetReleaseMonitor(db).evaluate_current(
        tenant_id="tenant-a",
        asset_id=asset.id,
        actor="release-monitor",
    )
    assert result["metrics"]["precision"] == 0.0
    assert result["rolled_back"] is True
