from __future__ import annotations

import asyncio
import json
import os
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any

from . import emby_collections, emby_users, favorites as favorites_service
from . import music_covers as music_service
from .music_covers import ProcessOptions

STATE_FILE = Path(os.environ.get("TASKS_STATE_FILE", os.environ.get("EMBY_TASKS_STATE_FILE", "cache/emby-tasks-state.json")))
CACHE_DIR = Path("cache")
EMBY_IMAGE_CACHE_DIR = CACHE_DIR / "emby_images"
CLEAN_POSTER_CACHE_DIR = CACHE_DIR / "clean_posters"
IMPORT_CACHE_DIR = CACHE_DIR / "imports"

TASK_DEFS: dict[str, dict[str, Any]] = {
    "orphan_custom_images": {
        "section": "emby",
        "title": "Delete orphaned custom images for missing Emby items",
        "description": "Requires access to Emby's internal metadata store. API-only deployments cannot safely locate these files.",
        "supports_run": False,
        "supports_automation": False,
        "requires": "emby_server_data",
        "run_label": "Run cleanup",
    },
    "empty_collections": {
        "section": "emby",
        "title": "Remove empty collections with zero items",
        "description": "Deletes BoxSet collections that no longer contain any media.",
        "supports_run": True,
        "supports_automation": True,
        "run_label": "Run cleanup",
    },
    "stale_favorites": {
        "section": "emby",
        "title": "Delete stale Favorites collections for removed users",
        "description": "Removes '{User} Favorites' collections whose owner no longer exists in Emby.",
        "supports_run": True,
        "supports_automation": True,
        "run_label": "Run cleanup",
    },
    "watched_favorites_cleanup": {
        "section": "emby",
        "title": "Bulk remove watched items from auto-managed collections",
        "description": "Runs watched-item cleanup across every detected Favorites collection.",
        "supports_run": True,
        "supports_automation": True,
        "run_label": "Run cleanup",
    },
    "duplicate_collections": {
        "section": "emby",
        "title": "Delete duplicate collections with the same normalized name",
        "description": "Keeps the best candidate in each duplicate-name group and removes the extras.",
        "supports_run": True,
        "supports_automation": True,
        "run_label": "Run cleanup",
    },
    "broken_library_paths": {
        "section": "emby",
        "title": "Remove broken library references where paths are not mounted",
        "description": "Checks Emby library paths against the app host's filesystem and removes paths that no longer exist.",
        "supports_run": True,
        "supports_automation": True,
        "run_label": "Run cleanup",
    },
    "generated_artwork_cache": {
        "section": "emby",
        "title": "Clear old generated artwork cache entries",
        "description": "Prunes stale generated preview and artwork cache files created by HomelabToolkit.",
        "supports_run": True,
        "supports_automation": True,
        "retention_days": 30,
        "run_label": "Run cleanup",
    },
    "unused_metadata_cache": {
        "section": "emby",
        "title": "Delete unused people/metadata images from app-managed cache",
        "description": "Removes stale Emby source image, poster-cleanup, and import cache files older than the configured retention window.",
        "supports_run": True,
        "supports_automation": True,
        "retention_days": 45,
        "run_label": "Run cleanup",
    },
    "navidrome_recent_maintenance": {
        "section": "navidrome",
        "title": "Maintain recently changed albums",
        "description": "Scans albums changed in the last 2 hours and normalizes folders, track names, and missing covers.",
        "supports_run": True,
        "supports_automation": True,
        "requires": "music_root",
        "run_label": "Run maintenance",
    },
    "navidrome_cover_backfill": {
        "section": "navidrome",
        "title": "Backfill missing album covers",
        "description": "Downloads missing cover.jpg artwork across the music library.",
        "supports_run": True,
        "supports_automation": True,
        "requires": "music_root",
        "run_label": "Fetch covers",
    },
    "navidrome_lyrics_backfill": {
        "section": "navidrome",
        "title": "Backfill missing lyrics sidecars",
        "description": "Downloads missing .lrc or .txt lyrics sidecars for tracks that do not already have them.",
        "supports_run": True,
        "supports_automation": True,
        "requires": "music_root",
        "run_label": "Fetch lyrics",
    },
    "navidrome_file_cleanup": {
        "section": "navidrome",
        "title": "Remove extra non-library files",
        "description": "Deletes non-audio, non-cover, and non-lyrics files from album folders.",
        "supports_run": True,
        "supports_automation": True,
        "requires": "music_root",
        "run_label": "Run cleanup",
    },
}

SECTION_TITLES = {
    "emby": "Emby Cleanup",
    "navidrome": "Navidrome Cleanup",
}


def _default_task_settings(task_id: str) -> dict[str, Any]:
    task = TASK_DEFS[task_id]
    return {
        "automation_enabled": False,
        "weekday": 0,
        "time": "03:00",
        "retention_days": int(task.get("retention_days", 30)),
    }


def default_settings() -> dict[str, dict[str, Any]]:
    return {task_id: _default_task_settings(task_id) for task_id in TASK_DEFS}


def normalize_settings(value: Any) -> dict[str, dict[str, Any]]:
    incoming = value if isinstance(value, dict) else {}
    merged = default_settings()
    for task_id, defaults in merged.items():
        raw = incoming.get(task_id)
        if not isinstance(raw, dict):
            continue
        defaults["automation_enabled"] = bool(raw.get("automation_enabled", defaults["automation_enabled"]))
        try:
            weekday = int(raw.get("weekday", defaults["weekday"]))
        except (TypeError, ValueError):
            weekday = defaults["weekday"]
        defaults["weekday"] = min(6, max(0, weekday))
        time_value = str(raw.get("time", defaults["time"])).strip() or defaults["time"]
        defaults["time"] = time_value
        try:
            retention = int(raw.get("retention_days", defaults["retention_days"]))
        except (TypeError, ValueError):
            retention = defaults["retention_days"]
        defaults["retention_days"] = min(3650, max(1, retention))
    return merged


def load_state() -> dict:
    if not STATE_FILE.exists():
        return {}
    try:
        return json.loads(STATE_FILE.read_text(encoding="utf-8"))
    except (OSError, ValueError):
        return {}


def save_state(state: dict) -> None:
    STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")


def _now() -> datetime:
    return datetime.now().astimezone()


def _week_key(current: datetime | None = None) -> str:
    iso = (current or _now()).isocalendar()
    return f"{iso.year}-W{iso.week:02d}"


def parse_time(value: str) -> tuple[int, int]:
    hh, mm = str(value).split(":", 1)
    hour = int(hh)
    minute = int(mm)
    if not (0 <= hour < 24 and 0 <= minute < 60):
        raise ValueError("time must be HH:MM")
    return hour, minute


def next_run_at(config: dict[str, Any], base: datetime | None = None) -> datetime:
    current = base or _now()
    hour, minute = parse_time(config["time"])
    days_ahead = (int(config["weekday"]) - current.weekday()) % 7
    target = current.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=days_ahead)
    if days_ahead == 0 and target <= current:
        target += timedelta(days=7)
    return target


def is_due(task_id: str, config: dict[str, Any], state: dict) -> bool:
    if not config.get("automation_enabled"):
        return False
    current = _now()
    hour, minute = parse_time(config["time"])
    if current.weekday() != int(config["weekday"]):
        return False
    if current < current.replace(hour=hour, minute=minute, second=0, microsecond=0):
        return False
    last_week = ((state.get("tasks") or {}).get(task_id) or {}).get("last_automation_week")
    return last_week != _week_key(current)


def record_run(task_id: str, result: dict, *, automated: bool) -> dict:
    state = load_state()
    tasks = state.setdefault("tasks", {})
    entry = tasks.setdefault(task_id, {})
    entry["last_run_at"] = _now().isoformat(timespec="seconds")
    entry["last_result"] = result
    entry["last_status"] = "ok" if result.get("ok", True) else "error"
    if automated:
        entry["last_automation_week"] = _week_key()
        entry["last_automated_at"] = entry["last_run_at"]
    save_state(state)
    return entry


def _music_root_available() -> bool:
    try:
        return music_service.MUSIC_ROOT.exists()
    except OSError:
        return False


def _supports_run(meta: dict[str, Any]) -> bool:
    requirement = meta.get("requires")
    if requirement == "emby_server_data":
        return False
    if requirement == "music_root":
        return bool(meta.get("supports_run")) and _music_root_available()
    return bool(meta.get("supports_run"))


def describe_tasks(settings: dict[str, dict[str, Any]]) -> list[dict]:
    state = load_state().get("tasks", {})
    items = []
    for task_id, meta in TASK_DEFS.items():
        config = settings[task_id]
        try:
            next_run = next_run_at(config).isoformat(timespec="seconds") if meta["supports_automation"] else None
            schedule_error = None
        except Exception as exc:
            next_run = None
            schedule_error = str(exc)
        items.append(
            {
                "id": task_id,
                "section": meta["section"],
                "section_title": SECTION_TITLES.get(meta["section"], meta["section"].title()),
                "title": meta["title"],
                "description": meta["description"],
                "supports_run": _supports_run(meta),
                "supports_automation": meta["supports_automation"],
                "requires": meta.get("requires"),
                "run_label": meta.get("run_label", "Run task"),
                "settings": config,
                "status": state.get(task_id, {}),
                "next_run_at": next_run,
                "schedule_error": schedule_error,
            }
        )
    return items


def _normalize_name(value: str) -> str:
    return re.sub(r"[^a-z0-9]+", "", (value or "").casefold())


def _age_cutoff_days(retention_days: int) -> float:
    return (_now() - timedelta(days=retention_days)).timestamp()


def _remove_paths(paths: list[Path], *, dry_run: bool) -> tuple[list[dict], int]:
    removed: list[dict] = []
    freed = 0
    for path in paths:
        try:
            stat = path.stat()
        except OSError:
            continue
        removed.append({"path": str(path), "size_bytes": stat.st_size, "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds")})
        freed += stat.st_size
        if not dry_run:
            try:
                path.unlink()
            except OSError:
                pass
    return removed, freed


async def _empty_collections(client, *, dry_run: bool, config: dict) -> dict:
    collections = await emby_collections.find_all_collections(client)
    zero_count_candidates = [c for c in collections if int(c.get("item_count") or 0) == 0]
    targets = []
    skipped = []
    for collection in zero_count_candidates:
        verified_count = await emby_collections.get_collection_item_count(client, collection["collection_id"])
        if verified_count == 0:
            targets.append({**collection, "verified_item_count": verified_count})
        else:
            skipped.append({**collection, "verified_item_count": verified_count})
    if not dry_run:
        for collection in targets:
            await client.delete(f"/Items/{collection['collection_id']}")
    return {
        "ok": True,
        "dry_run": dry_run,
        "candidate_count": len(zero_count_candidates),
        "verified_empty_count": len(targets),
        "skipped_after_verification": len(skipped),
        "matched_count": len(targets),
        "removed_count": 0 if dry_run else len(targets),
        "items": targets[:50],
        "skipped_items": skipped[:50],
        "message": (
            f"{len(targets)} verified empty collection(s) "
            f"{'would be removed' if dry_run else 'removed'}"
            f"{f'; {len(skipped)} skipped after verification' if skipped else ''}."
        ),
    }


async def _stale_favorites(client, *, dry_run: bool, config: dict) -> dict:
    users = {u["name"].casefold() for u in await emby_users.fetch_users(client)}
    targets = [c for c in await emby_collections.find_favorites_collections(client) if c["owner_name"] and c["owner_name"].casefold() not in users]
    if not dry_run:
        for collection in targets:
            await client.delete(f"/Items/{collection['collection_id']}")
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": len(targets),
        "removed_count": 0 if dry_run else len(targets),
        "items": targets[:50],
        "message": f"{len(targets)} stale Favorites collection(s) {'would be removed' if dry_run else 'removed'}.",
    }


async def _watched_favorites_cleanup(client, *, dry_run: bool, config: dict) -> dict:
    collections = await emby_collections.find_favorites_collections(client)
    users = {u["name"].casefold(): u for u in await emby_users.fetch_users(client)}
    summaries = []
    watched_found = 0
    removed_count = 0
    for collection in collections:
        owner = users.get((collection["owner_name"] or "").casefold())
        if not owner:
            continue
        result = await favorites_service.cleanup_watched(client, collection["collection_id"], owner["id"], dry_run=dry_run)
        if result["watched_found"]:
            summaries.append({
                "collection_id": collection["collection_id"],
                "collection_name": collection["collection_name"],
                "user_name": owner["name"],
                "watched_found": result["watched_found"],
                "removed_count": result["summary"]["removed_count"],
            })
            watched_found += result["watched_found"]
            removed_count += result["summary"]["removed_count"]
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": len(summaries),
        "watched_found": watched_found,
        "removed_count": removed_count,
        "items": summaries[:50],
        "message": f"{watched_found} watched item(s) {'would be removed' if dry_run else 'removed'} across {len(summaries)} collection(s).",
    }


async def _duplicate_collections(client, *, dry_run: bool, config: dict) -> dict:
    collections = await emby_collections.find_all_collections(client)
    groups: dict[str, list[dict]] = {}
    for collection in collections:
        groups.setdefault(_normalize_name(collection["collection_name"]), []).append(collection)
    duplicates = []
    for members in groups.values():
        if len(members) < 2 or not members[0]["collection_name"]:
            continue
        ordered = sorted(members, key=lambda item: (-int(item.get("item_count") or 0), len(item["collection_name"]), item["collection_name"].casefold(), item["collection_id"]))
        keep = ordered[0]
        for dupe in ordered[1:]:
            duplicates.append({
                "keep_id": keep["collection_id"],
                "keep_name": keep["collection_name"],
                "remove_id": dupe["collection_id"],
                "remove_name": dupe["collection_name"],
                "remove_item_count": dupe.get("item_count"),
            })
    if not dry_run:
        for dupe in duplicates:
            await client.delete(f"/Items/{dupe['remove_id']}")
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": len(duplicates),
        "removed_count": 0 if dry_run else len(duplicates),
        "items": duplicates[:50],
        "message": f"{len(duplicates)} duplicate collection(s) {'would be removed' if dry_run else 'removed'}.",
    }


async def _broken_library_paths(client, *, dry_run: bool, config: dict) -> dict:
    data = await client.get("/Library/SelectableMediaFolders")
    folders = data if isinstance(data, list) else data.get("Items", []) if isinstance(data, dict) else []
    broken = []
    for folder in folders:
        folder_id = folder.get("Id") or folder.get("Guid") or ""
        folder_name = folder.get("Name") or "Library"
        for sub in folder.get("SubFolders") or []:
            path = str(sub.get("Path") or "").strip()
            if not path:
                continue
            if not Path(path).exists():
                broken.append({"library_id": folder_id, "library_name": folder_name, "path": path})
    if not dry_run:
        for entry in broken:
            await client.post(
                "/Library/VirtualFolders/Paths/Delete",
                json={"Id": entry["library_id"], "Path": entry["path"], "RefreshLibrary": True},
            )
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": len(broken),
        "removed_count": 0 if dry_run else len(broken),
        "items": broken[:50],
        "message": f"{len(broken)} broken library path(s) {'would be removed' if dry_run else 'removed'}.",
    }


async def _generated_artwork_cache(client, *, dry_run: bool, config: dict) -> dict:
    cutoff = _age_cutoff_days(int(config["retention_days"]))
    targets = []
    if CACHE_DIR.exists():
        for path in CACHE_DIR.glob("*.png"):
            try:
                if path.stat().st_mtime < cutoff:
                    targets.append(path)
            except OSError:
                continue
    removed, freed = _remove_paths(targets, dry_run=dry_run)
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": len(removed),
        "removed_count": 0 if dry_run else len(removed),
        "freed_bytes": 0 if dry_run else freed,
        "items": removed[:50],
        "message": f"{len(removed)} generated artwork cache file(s) {'would be removed' if dry_run else 'removed'}.",
    }


async def _unused_metadata_cache(client, *, dry_run: bool, config: dict) -> dict:
    cutoff = _age_cutoff_days(int(config["retention_days"]))
    targets = []
    for root in (EMBY_IMAGE_CACHE_DIR, CLEAN_POSTER_CACHE_DIR, IMPORT_CACHE_DIR):
        if not root.exists():
            continue
        for path in root.rglob("*"):
            if not path.is_file():
                continue
            try:
                if path.stat().st_mtime < cutoff:
                    targets.append(path)
            except OSError:
                continue
    removed, freed = _remove_paths(targets, dry_run=dry_run)
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": len(removed),
        "removed_count": 0 if dry_run else len(removed),
        "freed_bytes": 0 if dry_run else freed,
        "items": removed[:50],
        "message": f"{len(removed)} metadata/source cache file(s) {'would be removed' if dry_run else 'removed'}.",
    }


def _summarize_music_actions(result: dict, *, dry_run: bool, interesting_actions: set[str], message_builder) -> dict:
    changes = [
        action for action in result.get("actions", [])
        if action.get("action") in interesting_actions and action.get("level") in {"dry", "ok"}
    ]
    change_count = len(changes)
    sample = changes[:50]
    return {
        "ok": True,
        "dry_run": dry_run,
        "matched_count": change_count,
        "removed_count": 0 if dry_run else change_count,
        "counts": result.get("counts", {}),
        "items": sample,
        "message": message_builder(change_count, dry_run),
    }


def _music_root_missing_result(*, dry_run: bool) -> dict:
    return {
        "ok": False,
        "dry_run": dry_run,
        "matched_count": 0,
        "removed_count": 0,
        "items": [],
        "message": f"Music root is not mounted: {music_service.MUSIC_ROOT}",
    }


async def _navidrome_recent_maintenance(client, *, dry_run: bool, config: dict) -> dict:
    if not _music_root_available():
        return _music_root_missing_result(dry_run=dry_run)
    options = ProcessOptions(folder_cleanup=True, rename=True, covers=True, dry_run=dry_run, recent_only=True)
    result = await asyncio.to_thread(music_service.process_library, options)
    return _summarize_music_actions(
        result,
        dry_run=dry_run,
        interesting_actions={"folder", "rename", "cover"},
        message_builder=lambda count, is_dry: f"{count} recent library change(s) {'would be applied' if is_dry else 'applied'}.",
    )


async def _navidrome_cover_backfill(client, *, dry_run: bool, config: dict) -> dict:
    if not _music_root_available():
        return _music_root_missing_result(dry_run=dry_run)
    options = ProcessOptions(covers=True, dry_run=dry_run)
    result = await asyncio.to_thread(music_service.process_library, options)
    return _summarize_music_actions(
        result,
        dry_run=dry_run,
        interesting_actions={"cover"},
        message_builder=lambda count, is_dry: f"{count} missing cover(s) {'would be downloaded' if is_dry else 'downloaded'}.",
    )


async def _navidrome_lyrics_backfill(client, *, dry_run: bool, config: dict) -> dict:
    if not _music_root_available():
        return _music_root_missing_result(dry_run=dry_run)
    options = ProcessOptions(lyrics=True, dry_run=dry_run, covers=False)
    result = await asyncio.to_thread(music_service.process_library, options)
    return _summarize_music_actions(
        result,
        dry_run=dry_run,
        interesting_actions={"lyrics"},
        message_builder=lambda count, is_dry: f"{count} lyric sidecar(s) {'would be downloaded' if is_dry else 'downloaded'}.",
    )


async def _navidrome_file_cleanup(client, *, dry_run: bool, config: dict) -> dict:
    if not _music_root_available():
        return _music_root_missing_result(dry_run=dry_run)
    options = ProcessOptions(file_cleanup=True, dry_run=dry_run, covers=False)
    result = await asyncio.to_thread(music_service.process_library, options)
    return _summarize_music_actions(
        result,
        dry_run=dry_run,
        interesting_actions={"remove"},
        message_builder=lambda count, is_dry: f"{count} extra file(s) {'would be removed' if is_dry else 'removed'}.",
    )


async def _unsupported(task_id: str, *, dry_run: bool) -> dict:
    return {
        "ok": False,
        "dry_run": dry_run,
        "matched_count": 0,
        "removed_count": 0,
        "items": [],
        "message": f"{TASK_DEFS[task_id]['title']} requires Emby server data access and is not available from this deployment yet.",
    }


RUNNERS = {
    "orphan_custom_images": _unsupported,
    "empty_collections": _empty_collections,
    "stale_favorites": _stale_favorites,
    "watched_favorites_cleanup": _watched_favorites_cleanup,
    "duplicate_collections": _duplicate_collections,
    "broken_library_paths": _broken_library_paths,
    "generated_artwork_cache": _generated_artwork_cache,
    "unused_metadata_cache": _unused_metadata_cache,
    "navidrome_recent_maintenance": _navidrome_recent_maintenance,
    "navidrome_cover_backfill": _navidrome_cover_backfill,
    "navidrome_lyrics_backfill": _navidrome_lyrics_backfill,
    "navidrome_file_cleanup": _navidrome_file_cleanup,
}


async def run_task(task_id: str, client, settings: dict[str, dict[str, Any]], *, dry_run: bool) -> dict:
    if task_id not in TASK_DEFS:
        raise KeyError(task_id)
    runner = RUNNERS[task_id]
    if runner is _unsupported:
        return await runner(task_id, dry_run=dry_run)
    return await runner(client, dry_run=dry_run, config=settings[task_id])
