"""Tests for the User Favourites feature (Emby Collections / BoxSets).

Uses a FakeEmbyClient (no network, no app.py import) and plain ``asyncio.run`` so
no pytest-asyncio plugin is required. Run from the repo root: ``python -m pytest``.
"""

import asyncio
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from services import emby_collections, emby_users, emby_watch_history, favorites
from services import recommendations as rec


def run(coro):
    return asyncio.run(coro)


# ── Item builders ────────────────────────────────────────────────────────────

def movie(item_id, name, *, genres=None, year=2014, studio="Acme",
          director="Jane Doe", actor="Bob Roe", ticks=72_000_000_000, rating=7.0):
    return {
        "Id": item_id, "Name": name, "Type": "Movie", "MediaType": "Video",
        "ProductionYear": year, "RunTimeTicks": ticks, "CommunityRating": rating,
        "Genres": genres or ["Action"],
        "Studios": [{"Name": studio}] if studio else [],
        "People": ([{"Name": director, "Type": "Director"}] if director else [])
                  + ([{"Name": actor, "Type": "Actor"}] if actor else []),
    }


# ── Fake Emby client ─────────────────────────────────────────────────────────

class FakeEmbyClient:
    def __init__(self, users=None, collections=None, collection_items=None,
                 watched_by_user=None, watched_history=None, candidates=None):
        self.users = users or []
        self.collections = collections or []
        self.collection_items = collection_items or {}     # cid -> [raw items]
        self.watched_by_user = watched_by_user or {}       # uid -> set(item_id)
        self.watched_history = watched_history or {}        # uid -> [raw items]
        self.candidates = candidates or {}                  # uid -> [raw items]
        self.calls = []                                     # recorded writes

    async def get(self, path, params=None):
        params = params or {}
        if path == "/Users":
            return list(self.users)
        if path.startswith("/Users/") and path.endswith("/Items"):
            uid = path.split("/")[2]
            if "ParentId" in params:  # collection children
                cid = params["ParentId"]
                watched = self.watched_by_user.get(uid, set())
                items = []
                for raw in self.collection_items.get(cid, []):
                    copy = dict(raw)
                    copy["UserData"] = {"Played": raw["Id"] in watched}
                    items.append(copy)
                return {"Items": items}
            if "Ids" in params:  # is_item_watched
                wanted = params["Ids"].split(",")
                watched = self.watched_by_user.get(uid, set())
                return {"Items": [{"Id": i, "UserData": {"Played": i in watched}} for i in wanted]}
            if params.get("Filters") == "IsUnplayed":  # candidate pool
                return {"Items": list(self.candidates.get(uid, []))}
        raise AssertionError(f"unexpected get {path} {params}")

    async def get_all(self, path, params=None):
        params = params or {}
        if path == "/Items" and params.get("IncludeItemTypes") == "BoxSet":
            return list(self.collections)
        if path.startswith("/Users/") and path.endswith("/Items"):
            uid = path.split("/")[2]
            if params.get("Filters") == "IsPlayed":
                return list(self.watched_history.get(uid, []))
        raise AssertionError(f"unexpected get_all {path} {params}")

    async def post(self, path, params=None, **kwargs):
        self.calls.append(("post", path, params or {}))
        cid = path.split("/")[2]
        for item_id in (params or {}).get("Ids", "").split(","):
            if item_id:
                self.collection_items.setdefault(cid, []).append(
                    {"Id": item_id, "Name": item_id, "Type": "Movie"})
        return {"ok": True}

    async def delete(self, path, params=None, **kwargs):
        self.calls.append(("delete", path, params or {}))
        cid = path.split("/")[2]
        remove = set((params or {}).get("Ids", "").split(","))
        self.collection_items[cid] = [
            i for i in self.collection_items.get(cid, []) if i["Id"] not in remove
        ]
        return {"ok": True}

    @property
    def writes(self):
        return [c for c in self.calls if c[0] in ("post", "delete")]


def base_client():
    """Two users, each with a favourites collection; Matt and Dave have watched
    different items to prove user-specific behaviour. Plus a themed collection
    that is not a favourites collection."""
    return FakeEmbyClient(
        users=[{"Id": "u-matt", "Name": "Matt"}, {"Id": "u-dave", "Name": "Dave"}],
        collections=[
            {"Id": "c-matt", "Name": "Matt Favorites", "ChildCount": 3},
            {"Id": "c-dave", "Name": "Dave Favorites", "ChildCount": 1},
            {"Id": "c-misc", "Name": "Marvel Universe", "ChildCount": 5},
        ],
        collection_items={
            "c-matt": [movie("m1", "Alpha"), movie("m2", "Bravo"), movie("m3", "Charlie")],
        },
        # Matt watched m1; Dave watched m2 (same items, different user)
        watched_by_user={"u-matt": {"m1"}, "u-dave": {"m2"}},
    )


# ── 1. Detecting favourites collections by name ──────────────────────────────

@pytest.mark.parametrize("name,owner", [
    ("Matt Favorites", "Matt"),
    ("Dave Favorites", "Dave"),
    ("Anna Maria Favorites", "Anna Maria"),
    ("favorites", None),
    ("Favorites", None),
    ("Marvel Universe", None),
    ("Favorites of Matt", None),
    ("", None),
    (None, None),
])
def test_parse_favorites_owner(name, owner):
    assert emby_collections.parse_favorites_owner(name) == owner


def test_list_favorites_users_only_matches_real_users():
    users = run(favorites.list_favorites_users(base_client()))
    assert [u["user_name"] for u in users] == ["Dave", "Matt"]  # "Marvel Universe" excluded


def test_find_all_collections_includes_non_favorites():
    cols = run(emby_collections.find_all_collections(base_client()))
    by_name = {c["collection_name"]: c for c in cols}
    assert set(by_name) == {"Matt Favorites", "Dave Favorites", "Marvel Universe"}
    assert by_name["Matt Favorites"]["is_favorites"] is True
    assert by_name["Marvel Universe"]["is_favorites"] is False
    assert by_name["Marvel Universe"]["owner_name"] is None


def test_list_collections_overview_maps_owner_user_id():
    overview = run(favorites.list_collections_overview(base_client()))
    by_name = {c["collection_name"]: c for c in overview["collections"]}
    assert by_name["Matt Favorites"]["owner_user_id"] == "u-matt"
    assert by_name["Marvel Universe"]["owner_user_id"] is None
    assert {u["name"] for u in overview["users"]} == {"Matt", "Dave"}


# ── 2. Listing collection items, user-specific watched status ────────────────

def test_list_collection_items_for_user():
    items = run(emby_collections.list_collection_items(base_client(), "c-matt", "u-matt"))
    assert [i["title"] for i in items] == ["Alpha", "Bravo", "Charlie"]
    assert items[0]["runtime_minutes"] == 120


def test_watched_status_is_user_specific():
    client = base_client()
    matt = run(emby_collections.list_collection_items(client, "c-matt", "u-matt"))
    dave = run(emby_collections.list_collection_items(client, "c-matt", "u-dave"))
    assert [i["watched"] for i in matt] == [True, False, False]   # Matt watched m1
    assert [i["watched"] for i in dave] == [False, True, False]   # Dave watched m2


def test_is_item_watched_user_specific():
    client = base_client()
    assert run(emby_watch_history.is_item_watched(client, "u-matt", "m1")) is True
    assert run(emby_watch_history.is_item_watched(client, "u-dave", "m1")) is False


# ── 3. Browse any collection; actions gated to favourites owner ──────────────

def test_collection_view_actions_enabled_for_any_collection():
    view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-matt"))
    assert view["actions_enabled"] is True
    assert [i["watched"] for i in view["items"]] == [True, False, False]


def test_collection_view_watched_follows_selected_user():
    view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-dave"))
    assert view["actions_enabled"] is True  # available regardless of owner
    assert [i["watched"] for i in view["items"]] == [False, True, False]


def test_collection_view_actions_enabled_for_non_favorites_collection():
    client = base_client()
    client.collection_items["c-misc"] = [movie("x1", "Iron Man")]
    view = run(favorites.get_collection_items_view(client, "c-misc", "u-matt"))
    assert view["is_favorites"] is False
    assert view["actions_enabled"] is True


def test_collection_view_missing_collection_raises_404():
    with pytest.raises(favorites.FavoritesError) as exc:
        run(favorites.get_collection_items_view(base_client(), "c-nope", "u-matt"))
    assert exc.value.status == 404


# ── 4. Cleanup: dry-run vs apply ─────────────────────────────────────────────

def test_cleanup_dry_run_does_not_modify_emby():
    client = base_client()
    res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=True))
    assert res["dry_run"] is True and res["applied"] is False
    assert res["watched_found"] == 1
    assert [r["item_id"] for r in res["removed"]] == ["m1"]
    assert client.writes == []
    assert len(client.collection_items["c-matt"]) == 3


def test_apply_cleanup_removes_only_watched_items():
    client = base_client()
    res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
    assert res["applied"] is True
    assert res["summary"]["removed_count"] == 1
    assert res["summary"]["final_count"] == 2
    deletes = [c for c in client.writes if c[0] == "delete"]
    assert len(deletes) == 1
    assert deletes[0][1] == "/Collections/c-matt/Items"
    assert deletes[0][2]["Ids"] == "m1"
    assert {i["Id"] for i in client.collection_items["c-matt"]} == {"m2", "m3"}
    assert res["log"][0]["item_id"] == "m1"
    assert res["log"][0]["reason"] == "watched-by-user"
    assert res["log"][0]["user"] == "Matt"
    assert res["log"][0]["collection"] == "Matt Favorites"


def test_cleanup_does_not_cross_users():
    """Dave watched m2; cleaning Matt's collection must not remove m2."""
    client = base_client()
    res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
    assert "m2" not in {r["item_id"] for r in res["removed"]}
    assert "m2" in {i["Id"] for i in client.collection_items["c-matt"]}


def test_cleanup_works_on_non_favorites_collection():
    """Cleanup is available for any collection, using the selected user's
    watched status."""
    client = base_client()
    client.collection_items["c-misc"] = [movie("x1", "Iron Man"), movie("x2", "Iron Man 2")]
    client.watched_by_user["u-matt"] = {"x1"}
    res = run(favorites.cleanup_watched(client, "c-misc", "u-matt", dry_run=False))
    assert res["applied"] is True
    assert [r["item_id"] for r in res["removed"]] == ["x1"]
    assert {i["Id"] for i in client.collection_items["c-misc"]} == {"x2"}


# ── 5. Recommendation scoring (pure) ─────────────────────────────────────────

def test_build_profile_and_score_candidate():
    watched = [emby_collections.normalize_item(movie(
        "w1", "Seed", genres=["Action", "Sci-Fi"], year=2014,
        studio="Acme", director="Jane Doe", actor="Bob Roe"))]
    profile = rec.build_profile(watched)
    assert profile.genres == {"Action", "Sci-Fi"}
    assert profile.decades == {2010}

    candidate = emby_collections.normalize_item(movie(
        "c1", "Match", genres=["Action", "Sci-Fi", "Drama"], year=2013,
        studio="Acme", director="Jane Doe", actor="Bob Roe"))
    # 2 genres*5 + director3 + actor2 + studio2 + decade1 + mediatype1 = 19
    assert rec.score_candidate(candidate, profile) == 19


def test_score_series_franchise_bonus():
    profile = rec.TasteProfile(series={"The Saga"}, media_types={"Video"})
    cand = {"series_name": "The Saga", "media_type": "Video", "genres": [], "year": None}
    assert rec.score_candidate(cand, profile) == rec.SCORE_SERIES + rec.SCORE_MEDIA_TYPE


def test_rank_orders_by_score_then_rating_then_title():
    profile = rec.TasteProfile(genres={"Action"}, media_types={"Video"},
                               studios={"Acme"}, directors={"Jane Doe"})
    low = emby_collections.normalize_item(movie("low", "Zeta", genres=["Action"],
                                                studio=None, director=None, actor=None, rating=9.0))
    high = emby_collections.normalize_item(movie("high", "Alpha", genres=["Action"],
                                                 studio="Acme", director="Jane Doe", actor=None, rating=1.0))
    ranked = rec.rank_candidates([low, high], profile)
    assert [r["id"] for r in ranked] == ["high", "low"]


def test_rank_drops_zero_score_candidates():
    profile = rec.TasteProfile(genres={"Action"})
    unrelated = {"id": "x", "genres": ["Cooking"], "media_type": "Audio", "year": 1980, "title": "X"}
    assert rec.rank_candidates([unrelated], profile) == []


# ── 6. Regenerate: exclusions ────────────────────────────────────────────────

def regen_client():
    client = base_client()
    client.watched_history["u-matt"] = [
        movie("w1", "Seed One", genres=["Action", "Sci-Fi"], year=2014),
        movie("w2", "Seed Two", genres=["Action"], year=2016),
    ]
    client.candidates["u-matt"] = [
        movie("c1", "Good One", genres=["Action", "Sci-Fi"], year=2015),
        movie("c2", "Good Two", genres=["Action"], year=2012),
        movie("m2", "Already In Collection", genres=["Action"], year=2015),  # in c-matt
        movie("w1", "Already Watched", genres=["Action"], year=2014),         # watched
    ]
    return client


def test_regenerate_excludes_watched_and_existing_collection_items():
    res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=True, target_size=25))
    ids = {r["item_id"] for r in res["recommended"]}
    assert "m2" not in ids   # already in collection
    assert "w1" not in ids   # already watched
    assert {"c1", "c2"} <= ids


def test_regenerate_dry_run_makes_no_writes():
    client = regen_client()
    res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
    assert res["applied"] is False
    assert client.writes == []


def test_regenerate_apply_adds_up_to_target():
    client = regen_client()
    res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=False, target_size=4))
    # collection had 3, target 4 -> add exactly 1
    assert res["summary"]["added_count"] == 1
    assert res["summary"]["final_count"] == 4
    posts = [c for c in client.writes if c[0] == "post"]
    assert len(posts) == 1
    assert posts[0][1] == "/Collections/c-matt/Items"
    assert res["recommended"][0]["item_id"] == "c1"  # highest score first


def test_regenerate_never_readds_watched():
    res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=False, target_size=25))
    added_ids = {r["item_id"] for r in res["recommended"]}
    assert "w1" not in added_ids and "w2" not in added_ids


# ── 7. Empty history ─────────────────────────────────────────────────────────

def test_regenerate_with_empty_history_recommends_nothing():
    client = base_client()
    client.watched_history["u-matt"] = []
    res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
    assert res["recommended"] == []
    assert "No watch history" in res["message"]
    assert client.writes == []


def test_empty_profile_build_candidates_returns_empty():
    profile = rec.build_profile([])
    assert profile.is_empty
    assert run(rec.build_candidates(base_client(), "u-matt", profile, set())) == []


# ── 8. Missing users / collections / API failures ────────────────────────────

def test_missing_user_raises_404():
    with pytest.raises(favorites.FavoritesError) as exc:
        run(favorites.cleanup_watched(base_client(), "c-matt", "u-ghost"))
    assert exc.value.status == 404


def test_missing_collection_raises_404():
    with pytest.raises(favorites.FavoritesError) as exc:
        run(favorites.cleanup_watched(base_client(), "c-nope", "u-matt"))
    assert exc.value.status == 404


def test_emby_api_failure_propagates():
    class BoomClient(FakeEmbyClient):
        async def get_all(self, path, params=None):
            raise RuntimeError("Emby exploded")

    client = BoomClient(users=[{"Id": "u-matt", "Name": "Matt"}])
    with pytest.raises(RuntimeError):
        run(favorites.list_collections_overview(client))


def test_regenerate_rejects_negative_target():
    with pytest.raises(favorites.FavoritesError):
        run(favorites.regenerate(base_client(), "c-matt", "u-matt", dry_run=True, target_size=-1))
