"""Tests for packages/domain/smart_match.py — 统一智能选素材算法。"""

import random
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any

import pytest

from packages.domain.smart_match import (
    SCORE_RANDOM_NOISE_MAX,
    SmartMatchResult,
    _diversity_select,
    _duration_bucket,
    score_asset,
    smart_select_assets,
)

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


@dataclass
class FakeAsset:
    """Minimal Asset-like object for testing."""

    id: str
    project_id: str = "proj-1"
    library_id: str = "lib-1"
    name: str = "test"
    storage_key: str = "key"
    mime_type: str = "video/mp4"
    file_size: int = 1000
    duration: float | None = None
    width: int | None = 1080
    height: int | None = 1920
    quality_score: float | None = None
    status: str = "ready"
    metadata: dict = field(default_factory=dict)
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))

    @property
    def file_type(self) -> str:
        if "/" in self.mime_type:
            return self.mime_type.split("/")[0]
        return self.mime_type


NOW = datetime(2026, 8, 5, 12, 0, 0, tzinfo=timezone.utc)


class _ZeroNoiseRandom(random.Random):
    """零噪声随机源：uniform(0, NOISE_MAX) 恒返回 0，使「按评分降序」类断言确定可复现。

    smart_select_assets 生产环境注入随机噪声（同分素材每次选出不同组合，#1743）；
    验证纯评分排序的单测用本随机源消除排序随机性。
    """

    def uniform(self, a, b):
        if a == 0.0 and b == SCORE_RANDOM_NOISE_MAX:
            return 0.0
        return super().uniform(a, b)


_ZERO_NOISE = _ZeroNoiseRandom(0)


# ── score_asset tests ────────────────────────────────────────────────────────


class TestScoreAsset:
    def test_high_quality_asset_scores_high(self):
        asset = FakeAsset(id="a1", quality_score=95, duration=15)
        score, breakdown = score_asset(asset, now=NOW)
        assert score > 70
        assert "quality" in breakdown
        assert "duration" in breakdown
        assert "recency" in breakdown
        assert "unused" in breakdown

    def test_low_quality_asset_scores_lower(self):
        asset_good = FakeAsset(id="a1", quality_score=95, duration=15)
        asset_bad = FakeAsset(
            id="a2",
            quality_score=20,
            duration=15,
            created_at=NOW - timedelta(days=60),
            metadata={"generation_use_count": 10},
        )
        score_good, _ = score_asset(asset_good, now=NOW)
        score_bad, _ = score_asset(asset_bad, now=NOW)
        assert score_bad < score_good

    def test_no_quality_score_defaults_to_50(self):
        asset = FakeAsset(id="a1", quality_score=None, duration=15)
        score, breakdown = score_asset(asset, now=NOW)
        # quality component should be 50 * 0.4 = 20
        assert breakdown["quality"] == pytest.approx(20.0, abs=0.1)

    def test_optimal_duration_5_to_30_gets_full_score(self):
        for dur in [5, 10, 20, 30]:
            asset = FakeAsset(id="a1", quality_score=50, duration=dur)
            _, breakdown = score_asset(asset, now=NOW)
            # duration component should be 100 * 0.3 = 30
            assert breakdown["duration"] == pytest.approx(30.0, abs=0.1)

    def test_short_duration_below_5s_penalized(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=2)
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["duration"] < 30.0

    def test_long_duration_above_30s_penalized(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=120)
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["duration"] < 30.0

    def test_zero_duration_gives_moderate_score(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=0)
        _, breakdown = score_asset(asset, now=NOW)
        # duration_fitness = 30.0, component = 30 * 0.3 = 9
        assert breakdown["duration"] == pytest.approx(9.0, abs=0.1)

    def test_unused_asset_gets_full_bonus(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={})
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["unused"] == pytest.approx(10.0, abs=0.1)

    def test_used_asset_gets_reduced_bonus(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": 5})
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["unused"] == pytest.approx(3.0, abs=0.1)

    def test_dirty_metadata_use_count_string_does_not_crash(self):
        """int() conversion of non-numeric metadata should not raise, should default to 0."""
        asset = FakeAsset(id="a1", quality_score=50, duration=15, metadata={"generation_use_count": "high"})
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["unused"] == pytest.approx(10.0, abs=0.1)  # use_count=0 → unused_score=100 → 100*0.1=10

    def test_recent_asset_scores_higher_recency(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=1))
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["recency"] > 15  # > 75% of max 20

    def test_old_asset_scores_lower_recency(self):
        asset = FakeAsset(id="a1", quality_score=50, duration=15, created_at=NOW - timedelta(days=60))
        _, breakdown = score_asset(asset, now=NOW)
        assert breakdown["recency"] < 5  # heavily decayed


# ── _duration_bucket tests ───────────────────────────────────────────────────


class TestDurationBucket:
    def test_short(self):
        assert _duration_bucket(5) == "short"
        assert _duration_bucket(9.9) == "short"

    def test_medium(self):
        assert _duration_bucket(10) == "medium"
        assert _duration_bucket(30) == "medium"

    def test_long(self):
        assert _duration_bucket(31) == "long"
        assert _duration_bucket(120) == "long"

    def test_unknown(self):
        assert _duration_bucket(None) == "unknown"
        assert _duration_bucket(0) == "unknown"
        assert _duration_bucket(-1) == "unknown"


# ── smart_select_assets tests ────────────────────────────────────────────────


class TestSmartSelectAssets:
    def test_filters_non_ready_assets(self):
        assets = [
            FakeAsset(id="a1", status="ready", quality_score=80, duration=15),
            FakeAsset(id="a2", status="uploading", quality_score=90, duration=15),
            FakeAsset(id="a3", status="error", quality_score=70, duration=15),
        ]
        results = smart_select_assets(assets)
        assert len(results) == 1
        assert results[0].asset.id == "a1"

    def test_filters_by_kind(self):
        assets = [
            FakeAsset(id="a1", mime_type="video/mp4", quality_score=80, duration=15),
            FakeAsset(id="a2", mime_type="image/png", quality_score=90, duration=0),
            FakeAsset(id="a3", mime_type="audio/mp3", quality_score=70, duration=30),
        ]
        results = smart_select_assets(assets, kind="video")
        assert len(results) == 1
        assert results[0].asset.id == "a1"

    def test_respects_limit(self):
        assets = [FakeAsset(id=f"a{i}", quality_score=50 + i, duration=15) for i in range(20)]
        results = smart_select_assets(assets, limit=5)
        assert len(results) == 5

    def test_returns_sorted_by_score_descending(self):
        assets = [
            FakeAsset(id="low", quality_score=20, duration=15),
            FakeAsset(id="high", quality_score=95, duration=15),
            FakeAsset(id="mid", quality_score=60, duration=15),
        ]
        results = smart_select_assets(assets, rng=_ZERO_NOISE)
        scores = [r.score for r in results]
        assert scores == sorted(scores, reverse=True)
        assert results[0].asset.id == "high"

    def test_empty_list_returns_empty(self):
        assert smart_select_assets([]) == []

    def test_all_non_ready_returns_empty(self):
        assets = [FakeAsset(id="a1", status="uploading")]
        assert smart_select_assets(assets) == []

    def test_diversity_select_balances_duration_buckets(self):
        """When limit is less than total, diversity select should pick from multiple buckets."""
        assets = []
        # 10 short clips
        for i in range(10):
            assets.append(FakeAsset(id=f"s{i}", quality_score=80, duration=5))
        # 10 medium clips
        for i in range(10):
            assets.append(FakeAsset(id=f"m{i}", quality_score=80, duration=20))
        # 10 long clips
        for i in range(10):
            assets.append(FakeAsset(id=f"l{i}", quality_score=80, duration=60))

        results = smart_select_assets(assets, limit=6)
        assert len(results) == 6
        # Should have items from multiple buckets
        buckets = {_duration_bucket(r.asset.duration) for r in results}
        assert len(buckets) >= 2  # at least 2 different duration buckets

    def test_no_limit_returns_all(self):
        assets = [FakeAsset(id=f"a{i}", quality_score=50 + i, duration=15) for i in range(10)]
        results = smart_select_assets(assets, limit=None)
        assert len(results) == 10

    def test_score_includes_breakdown(self):
        asset = FakeAsset(id="a1", quality_score=80, duration=15, metadata={})
        results = smart_select_assets([asset])
        assert len(results) == 1
        r = results[0]
        assert r.score > 0
        assert set(r.breakdown.keys()) == {"quality", "duration", "recency", "unused"}

    def test_image_assets_can_be_selected(self):
        assets = [
            FakeAsset(id="img1", mime_type="image/jpeg", quality_score=90, duration=None),
            FakeAsset(id="img2", mime_type="image/png", quality_score=70, duration=None),
        ]
        results = smart_select_assets(assets, kind="image", rng=_ZERO_NOISE)
        assert len(results) == 2
        assert results[0].asset.id == "img1"

    def test_str_enum_status_handled(self):
        """Test that StrEnum-like status objects are handled correctly."""

        class StrEnumLike:
            def __init__(self, value):
                self.value = value

        asset = FakeAsset(id="a1", quality_score=80, duration=15)
        asset.status = StrEnumLike("ready")
        results = smart_select_assets([asset])
        assert len(results) == 1


# ── _diversity_select tests ──────────────────────────────────────────────────


class TestDiversitySelect:
    def test_picks_from_all_buckets(self):
        results = [
            SmartMatchResult(asset=FakeAsset(id="s1", duration=5), score=90),
            SmartMatchResult(asset=FakeAsset(id="s2", duration=3), score=85),
            SmartMatchResult(asset=FakeAsset(id="m1", duration=20), score=80),
            SmartMatchResult(asset=FakeAsset(id="l1", duration=60), score=75),
        ]
        selected = _diversity_select(results, limit=3)
        assert len(selected) == 3
        ids = {r.asset.id for r in selected}
        # Should have at least one from short, medium, long
        assert "s1" in ids or "s2" in ids
        assert "m1" in ids
        assert "l1" in ids

    def test_limit_larger_than_input_returns_all(self):
        results = [
            SmartMatchResult(asset=FakeAsset(id="a1", duration=5), score=90),
        ]
        selected = _diversity_select(results, limit=10)
        assert len(selected) == 1


# ── API endpoint tests ───────────────────────────────────────────────────────

import os
import sys
from pathlib import Path
from unittest.mock import MagicMock

os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")

from fastapi import FastAPI
from fastapi.testclient import TestClient

sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))

from app.api.routes.assets import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
    get_asset_library_repository,
    get_asset_repository,
    get_project_repository,
)

from packages.domain import (
    Asset,
    AssetLibrary,
    AssetLibraryKind,
    AssetStatus,
    ClassificationStatus,
    Project,
    User,
)


class _StubProjectRepo:
    def __init__(self, projects):
        self._projects = projects

    def find_by_id(self, pid):
        return self._projects.get(pid)


class _StubAssetLibraryRepo:
    def __init__(self, libraries):
        self._libraries = libraries

    def get(self, lid):
        return self._libraries.get(lid)


class _StubAssetRepo:
    def __init__(self, assets):
        self._assets = assets

    def find_by_library(self, lid, skip=0, limit=100, status=None):
        result = [a for a in self._assets if a.library_id == lid]
        if status:
            result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
        return result[skip : skip + limit]

    def find_by_library_and_file_type(self, lid, file_type, skip=0, limit=100, status=None):
        result = [a for a in self._assets if a.library_id == lid and a.file_type == file_type]
        if status:
            result = [a for a in result if (a.status.value if hasattr(a.status, "value") else a.status) in status]
        return result[skip : skip + limit]


def _make_app(asset_repo, lib_repo, proj_repo):
    app = FastAPI()
    app.include_router(router, prefix="/assets")

    fake_user = MagicMock()
    fake_user.user = User(id="user-1", email="test@test.com", display_name="Test")

    app.dependency_overrides[get_current_user] = lambda: AuthenticatedUser(user=fake_user.user)
    app.dependency_overrides[get_asset_repository] = lambda: asset_repo
    app.dependency_overrides[get_asset_library_repository] = lambda: lib_repo
    app.dependency_overrides[get_project_repository] = lambda: proj_repo
    app.dependency_overrides[get_storage_service] = lambda: MagicMock()
    return app


def _make_test_data():
    project = Project(id="proj-1", name="Test", owner_user_id="user-1")
    library = AssetLibrary(
        id="lib-1",
        project_id="proj-1",
        name="Videos",
        kind=AssetLibraryKind.VIDEO,
    )
    assets = [
        Asset.create(
            project_id="proj-1",
            library_id="lib-1",
            name="v1.mp4",
            storage_key="k1",
            mime_type="video/mp4",
            quality_score=90,
            duration=15,
            status=AssetStatus.READY,
        ),
        Asset.create(
            project_id="proj-1",
            library_id="lib-1",
            name="v2.mp4",
            storage_key="k2",
            mime_type="video/mp4",
            quality_score=50,
            duration=25,
            status=AssetStatus.READY,
        ),
        Asset.create(
            project_id="proj-1",
            library_id="lib-1",
            name="v3.mp4",
            storage_key="k3",
            mime_type="video/mp4",
            quality_score=30,
            duration=60,
            status=AssetStatus.READY,
        ),
    ]
    return project, library, assets


class TestSmartMatchEndpoint:
    def test_returns_scored_items(self):
        from unittest.mock import patch

        project, library, assets = _make_test_data()
        app = _make_app(
            _StubAssetRepo(assets),
            _StubAssetLibraryRepo({"lib-1": library}),
            _StubProjectRepo({"proj-1": project}),
        )
        client = TestClient(app)
        with patch("packages.domain.smart_match.random.Random", return_value=_ZERO_NOISE):
            resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
        assert resp.status_code == 200, f"Got {resp.status_code}: {resp.text}"
        data = resp.json()
        assert len(data["items"]) == 3
        assert data["total_candidates"] == 3
        # Sorted by score descending
        scores = [item["score"] for item in data["items"]]
        assert scores == sorted(scores, reverse=True)
        # Each item has breakdown
        for item in data["items"]:
            assert "quality" in item["breakdown"]
            assert "duration" in item["breakdown"]
        # P0 回归：扁平结构——素材字段在 item 顶层，无 asset 包装层
        for item in data["items"]:
            assert item["id"]
            assert "asset" not in item
            assert item["mime_type"].startswith("video/")
            assert "usable" in item

    def test_limit_parameter(self):
        project, library, assets = _make_test_data()
        app = _make_app(
            _StubAssetRepo(assets),
            _StubAssetLibraryRepo({"lib-1": library}),
            _StubProjectRepo({"proj-1": project}),
        )
        client = TestClient(app)
        resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "limit": 2})
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["items"]) == 2
        assert data["total_candidates"] == 3

    def test_kind_filter(self):
        project, library, assets = _make_test_data()
        # Add an image asset
        img_asset = Asset.create(
            project_id="proj-1",
            library_id="lib-1",
            name="img.png",
            storage_key="k4",
            mime_type="image/png",
            quality_score=95,
            status=AssetStatus.READY,
        )
        assets.append(img_asset)
        app = _make_app(
            _StubAssetRepo(assets),
            _StubAssetLibraryRepo({"lib-1": library}),
            _StubProjectRepo({"proj-1": project}),
        )
        client = TestClient(app)
        resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "kind": "image"})
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["items"]) == 1
        assert data["items"][0]["mime_type"] == "image/png"
        # total_candidates should only count filtered-by-kind assets (1 image, not 3 videos)
        assert data["total_candidates"] == 1

    def test_kind_filter_video_total_candidates(self):
        """Verify total_candidates reflects kind filtering, not total assets."""
        project, library, assets = _make_test_data()
        img_asset = Asset.create(
            project_id="proj-1",
            library_id="lib-1",
            name="img.png",
            storage_key="k4",
            mime_type="image/png",
            quality_score=95,
            status=AssetStatus.READY,
        )
        assets.append(img_asset)
        app = _make_app(
            _StubAssetRepo(assets),
            _StubAssetLibraryRepo({"lib-1": library}),
            _StubProjectRepo({"proj-1": project}),
        )
        client = TestClient(app)
        resp = client.post("/assets/smart-match", json={"library_id": "lib-1", "kind": "video"})
        assert resp.status_code == 200
        data = resp.json()
        assert len(data["items"]) == 3
        # total_candidates = 3 videos only, not 4 (3 videos + 1 image)
        assert data["total_candidates"] == 3

    def test_library_not_found_returns_404(self):
        app = _make_app(
            _StubAssetRepo([]),
            _StubAssetLibraryRepo({}),
            _StubProjectRepo({}),
        )
        client = TestClient(app)
        resp = client.post("/assets/smart-match", json={"library_id": "nonexistent"})
        assert resp.status_code == 404

    def test_empty_library_returns_empty_items(self):
        project = Project(id="proj-1", name="Test", owner_user_id="user-1")
        library = AssetLibrary(
            id="lib-1",
            project_id="proj-1",
            name="Empty",
            kind=AssetLibraryKind.VIDEO,
        )
        app = _make_app(
            _StubAssetRepo([]),
            _StubAssetLibraryRepo({"lib-1": library}),
            _StubProjectRepo({"proj-1": project}),
        )
        client = TestClient(app)
        resp = client.post("/assets/smart-match", json={"library_id": "lib-1"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["items"] == []
        assert data["total_candidates"] == 0
