#!/usr/bin/env python3
"""Search every configured stock provider at once, choose by eye, then download.

Three things this does that a per-provider script cannot:

  * **One query, all providers.** Pexels, Pixabay and Openverse are searched in
    parallel and the results are merged, deduplicated and ranked together, so the
    best clip wins rather than the first provider's best clip.

  * **A contact sheet.** Stock footage cannot be chosen from JSON. Thumbnails are
    fetched and laid out in a grid labelled with the id, so the actual decision is
    visual — which is the whole point of searching before downloading.

  * **Provenance that survives.** Nothing is downloaded without recording the
    author, the source page, the licence and its URL, the query that found it and
    the SHA-256 of the bytes. The manifest is cumulative: a later search adds to it
    instead of replacing it.

Search never downloads full media. Downloading requires explicit ids, because
"whatever the API returned first" is not an editorial decision.
"""
from __future__ import annotations

import argparse
import concurrent.futures
import hashlib
import json
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import requests

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

from skill_config import CACHE, PROVIDERS, emit, optional_secret, utf8_stdout  # noqa: E402

USER_AGENT = "ai-pro-video-skill/4"
CACHE_TTL = 24 * 3600

LICENCES = {
    "pexels": ("Pexels License", "https://www.pexels.com/license/"),
    "pixabay": ("Pixabay Content License", "https://pixabay.com/service/license-summary/"),
    "openverse": ("см. license в записи", "https://openverse.org/"),
    "jamendo": ("Creative Commons, зависит от трека", "https://devportal.jamendo.com/"),
}


def cached_get(url: str, params: dict, headers: dict | None, cache_key: str,
               ttl: int = CACHE_TTL) -> dict | None:
    """GET with a disk cache. Keys never include the API key."""
    directory = CACHE / "stock"
    directory.mkdir(parents=True, exist_ok=True)
    path = directory / f"{hashlib.sha256(cache_key.encode()).hexdigest()}.json"
    if path.exists() and time.time() - path.stat().st_mtime < ttl:
        try:
            return json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            pass
    response = requests.get(url, params=params, headers={"User-Agent": USER_AGENT,
                                                        **(headers or {})}, timeout=40)
    if response.status_code >= 400:
        return {"__error__": f"HTTP {response.status_code}", "__body__": response.text[:300]}
    payload = response.json()
    path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
    return payload


# ------------------------------------------------------------------- providers

def search_pexels(query: str, kind: str, orientation: str, count: int) -> list[dict]:
    key = optional_secret("PEXELS_API_KEY")
    if not key:
        return [{"__provider__": "pexels", "__skipped__": "PEXELS_API_KEY не задан"}]
    if kind not in ("video", "photo"):
        return []
    endpoint = ("https://api.pexels.com/videos/search" if kind == "video"
                else "https://api.pexels.com/v1/search")
    params = {"query": query, "per_page": min(80, max(count * 2, 10))}
    if orientation != "any":
        params["orientation"] = orientation
    payload = cached_get(endpoint, params, {"Authorization": key},
                         f"pexels|{kind}|{query}|{orientation}|{params['per_page']}")
    if not payload or "__error__" in payload:
        return [{"__provider__": "pexels", "__error__": (payload or {}).get("__error__", "no response")}]
    items = []
    for entry in payload.get("videos" if kind == "video" else "photos", []):
        if kind == "video":
            files = [f for f in entry.get("video_files", []) if f.get("link")]
            if not files:
                continue
            best = max(files, key=lambda f: (f.get("width") or 0) * (f.get("height") or 0))
            thumbnail = entry.get("image")
            width, height = best.get("width"), best.get("height")
            download = best["link"]
            variants = [
                {"width": f.get("width"), "height": f.get("height"),
                 "fps": f.get("fps"), "url": f.get("link")}
                for f in sorted(files, key=lambda f: -((f.get("width") or 0)))
            ]
        else:
            source = entry.get("src", {})
            download = source.get("original")
            thumbnail = source.get("medium") or source.get("small")
            width, height = entry.get("width"), entry.get("height")
            variants = [{"name": name, "url": url} for name, url in source.items()]
        items.append({
            "provider": "pexels",
            "kind": kind,
            "id": entry.get("id"),
            "width": width,
            "height": height,
            "duration": entry.get("duration"),
            "creator": (entry.get("user") or {}).get("name") or entry.get("photographer"),
            "creator_url": (entry.get("user") or {}).get("url") or entry.get("photographer_url"),
            "source_page": entry.get("url"),
            "thumbnail": thumbnail,
            "download": download,
            "variants": variants,
            "tags": entry.get("alt") or "",
        })
    return items


def search_pixabay(query: str, kind: str, orientation: str, count: int) -> list[dict]:
    key = optional_secret("PIXABAY_API_KEY")
    if not key:
        return [{"__provider__": "pixabay", "__skipped__": "PIXABAY_API_KEY не задан"}]
    if kind not in ("video", "photo", "illustration"):
        return []
    endpoint = ("https://pixabay.com/api/videos/" if kind == "video"
                else "https://pixabay.com/api/")
    params = {"key": key, "q": query, "per_page": min(200, max(count * 2, 10)),
              "safesearch": "true"}
    if kind != "video":
        params["image_type"] = "photo" if kind == "photo" else "illustration"
    if orientation != "any":
        params["orientation"] = {"portrait": "vertical", "landscape": "horizontal"}.get(
            orientation, "all")
    signature = "|".join(f"{k}={v}" for k, v in params.items() if k != "key")
    payload = cached_get(endpoint, params, None, f"pixabay|{kind}|{signature}")
    if not payload or "__error__" in payload:
        return [{"__provider__": "pixabay",
                 "__error__": (payload or {}).get("__error__", "no response")}]
    items = []
    for entry in payload.get("hits", []):
        if kind == "video":
            files = entry.get("videos", {})
            best_name = next((name for name in ("large", "medium", "small", "tiny")
                              if files.get(name, {}).get("url")), None)
            if not best_name:
                continue
            best = files[best_name]
            download = best.get("url")
            width, height = best.get("width"), best.get("height")
            thumbnail = best.get("thumbnail") or (
                f"https://i.vimeocdn.com/video/{entry.get('picture_id')}_640x360.jpg"
                if entry.get("picture_id") else None
            )
            variants = [{"name": name, "width": spec.get("width"),
                         "height": spec.get("height"), "url": spec.get("url")}
                        for name, spec in files.items() if isinstance(spec, dict)]
        else:
            download = entry.get("largeImageURL") or entry.get("webformatURL")
            thumbnail = entry.get("previewURL") or entry.get("webformatURL")
            width, height = entry.get("imageWidth"), entry.get("imageHeight")
            variants = [{"name": "webformat", "url": entry.get("webformatURL")},
                        {"name": "large", "url": entry.get("largeImageURL")}]
        items.append({
            "provider": "pixabay",
            "kind": kind,
            "id": entry.get("id"),
            "width": width,
            "height": height,
            "duration": entry.get("duration"),
            "creator": entry.get("user"),
            "creator_url": f"https://pixabay.com/users/{entry.get('user')}-{entry.get('user_id')}/",
            "source_page": entry.get("pageURL"),
            "thumbnail": thumbnail,
            "download": download,
            "variants": variants,
            "tags": entry.get("tags", ""),
        })
    return items


def search_openverse(query: str, kind: str, orientation: str, count: int) -> list[dict]:
    """No key required, but rate limited — used as a supplement, not a main source."""
    if kind not in ("photo", "audio"):
        return []
    endpoint = ("https://api.openverse.org/v1/images/" if kind == "photo"
                else "https://api.openverse.org/v1/audio/")
    params = {"q": query, "page_size": min(20, max(count, 5))}
    if kind == "photo" and orientation != "any":
        params["aspect_ratio"] = {"portrait": "tall", "landscape": "wide"}.get(
            orientation, "square")
    payload = cached_get(endpoint, params, None,
                         f"openverse|{kind}|{query}|{orientation}|{params['page_size']}")
    if not payload or "__error__" in payload:
        return [{"__provider__": "openverse",
                 "__error__": (payload or {}).get("__error__", "no response")}]
    items = []
    for entry in payload.get("results", []):
        items.append({
            "provider": "openverse",
            "kind": kind,
            "id": entry.get("id"),
            "width": entry.get("width"),
            "height": entry.get("height"),
            "duration": (entry.get("duration") or 0) / 1000 if entry.get("duration") else None,
            "creator": entry.get("creator"),
            "creator_url": entry.get("creator_url"),
            "source_page": entry.get("foreign_landing_url"),
            "thumbnail": entry.get("thumbnail"),
            "download": entry.get("url"),
            "variants": [],
            "tags": ", ".join(tag.get("name", "") for tag in (entry.get("tags") or [])[:8]),
            "license": entry.get("license"),
            "license_url": entry.get("license_url"),
            "license_version": entry.get("license_version"),
            "attribution": entry.get("attribution"),
        })
    return items


def search_jamendo(query: str, kind: str, orientation: str, count: int) -> list[dict]:
    key = optional_secret("JAMENDO_CLIENT_ID")
    if kind != "music":
        return []
    if not key:
        return [{"__provider__": "jamendo",
                 "__skipped__": "JAMENDO_CLIENT_ID не задан — музыкальный поиск недоступен"}]
    payload = cached_get(
        "https://api.jamendo.com/v3.0/tracks/",
        {"client_id": key, "format": "json", "limit": min(100, max(count, 10)),
         "search": query, "include": "musicinfo+licenses", "audioformat": "mp32"},
        None, f"jamendo|{query}|{count}",
    )
    if not payload or "__error__" in payload:
        return [{"__provider__": "jamendo",
                 "__error__": (payload or {}).get("__error__", "no response")}]
    items = []
    for entry in payload.get("results", []):
        items.append({
            "provider": "jamendo",
            "kind": "music",
            "id": entry.get("id"),
            "width": None, "height": None,
            "duration": entry.get("duration"),
            "creator": entry.get("artist_name"),
            "creator_url": entry.get("artist_id"),
            "source_page": entry.get("shareurl"),
            "thumbnail": entry.get("album_image"),
            "download": entry.get("audiodownload") or entry.get("audio"),
            "variants": [],
            "tags": ", ".join((entry.get("musicinfo") or {}).get("tags", {}).get("genres", [])),
            "license": entry.get("license_ccurl"),
            "license_url": entry.get("license_ccurl"),
            "title": entry.get("name"),
            "album": entry.get("album_name"),
        })
    return items


SEARCHERS = {
    "pexels": search_pexels,
    "pixabay": search_pixabay,
    "openverse": search_openverse,
    "jamendo": search_jamendo,
}


# ---------------------------------------------------------------------- ranking

def score(item: dict, orientation: str, minimum_pixels: int,
          want_duration: tuple[float, float] | None) -> float:
    """Rank on fitness for the edit, not on provider order."""
    value = 0.0
    width = item.get("width") or 0
    height = item.get("height") or 0
    pixels = width * height
    if pixels:
        value += min(pixels / 2_073_600, 4.0)  # up to 4 points for resolution
        if pixels < minimum_pixels:
            value -= 6.0
        if orientation == "portrait" and height > width:
            value += 2.0
        elif orientation == "landscape" and width > height:
            value += 2.0
        elif orientation != "any":
            value -= 2.0
    duration = item.get("duration")
    if want_duration and duration:
        low, high = want_duration
        if low <= duration <= high:
            value += 2.0
        else:
            value -= min(2.0, abs(duration - (low + high) / 2) / 10)
    if item.get("thumbnail"):
        value += 0.5
    return value


def deduplicate(items: list[dict]) -> tuple[list[dict], int]:
    """Providers mirror each other's uploads; keep the highest-resolution copy."""
    best: dict[tuple, dict] = {}
    removed = 0
    for item in items:
        signature = (
            item.get("kind"),
            (item.get("creator") or "").casefold(),
            round((item.get("duration") or 0) or 0),
            (item.get("tags") or "").casefold()[:60],
        )
        existing = best.get(signature)
        area = (item.get("width") or 0) * (item.get("height") or 0)
        if existing is None:
            best[signature] = item
        elif area > (existing.get("width") or 0) * (existing.get("height") or 0):
            best[signature] = item
            removed += 1
        else:
            removed += 1
    return list(best.values()), removed


# ------------------------------------------------------------------ downloading

def download(url: str, destination: Path) -> int:
    destination.parent.mkdir(parents=True, exist_ok=True)
    total = 0
    with requests.get(url, stream=True, timeout=300,
                      headers={"User-Agent": USER_AGENT}) as response:
        response.raise_for_status()
        with destination.open("wb") as handle:
            for chunk in response.iter_content(1 << 20):
                if chunk:
                    handle.write(chunk)
                    total += len(chunk)
    return total


def merge_manifest(path: Path, additions: list[dict]) -> list[dict]:
    existing: list[dict] = []
    if path.exists():
        try:
            existing = json.loads(path.read_text(encoding="utf-8"))
        except json.JSONDecodeError:
            existing = []
    merged = {(item.get("provider"), str(item.get("id"))): item for item in existing}
    for item in additions:
        merged[(item.get("provider"), str(item.get("id")))] = item
    return sorted(merged.values(), key=lambda item: (str(item.get("provider")),
                                                     str(item.get("id"))))


def contact_sheet(items: list[dict], destination: Path, columns: int = 5,
                  tile: int = 320) -> dict:
    """Grid of thumbnails so the choice is made by looking, not by reading JSON."""
    try:
        from PIL import Image, ImageDraw
    except ImportError:
        return {"built": False, "reason": "Pillow не установлен"}
    thumbnails: list[tuple[dict, Path]] = []
    directory = CACHE / "stock" / "thumbs"
    directory.mkdir(parents=True, exist_ok=True)
    for item in items:
        url = item.get("thumbnail")
        if not url:
            continue
        name = f"{item['provider']}_{item['id']}.jpg"
        path = directory / name
        if not path.exists():
            try:
                download(url, path)
            except Exception:
                continue
        thumbnails.append((item, path))
    if not thumbnails:
        return {"built": False, "reason": "ни один превью не удалось скачать"}

    rows = (len(thumbnails) + columns - 1) // columns
    cell_height = tile + 34
    sheet = Image.new("RGB", (columns * tile, rows * cell_height), (16, 18, 22))
    draw = ImageDraw.Draw(sheet)
    for index, (item, path) in enumerate(thumbnails):
        try:
            image = Image.open(path).convert("RGB")
        except Exception:
            continue
        image.thumbnail((tile, tile))
        x = (index % columns) * tile
        y = (index // columns) * cell_height
        sheet.paste(image, (x + (tile - image.width) // 2, y + 30 + (tile - image.height) // 2))
        label = f"{item['provider'][:3]}:{item['id']}"
        size = f"{item.get('width')}x{item.get('height')}"
        if item.get("duration"):
            size += f" {round(float(item['duration']))}s"
        draw.text((x + 6, y + 4), label, fill=(0, 255, 140))
        draw.text((x + 6, y + 16), size, fill=(160, 170, 180))
    destination.parent.mkdir(parents=True, exist_ok=True)
    sheet.save(destination, quality=88)
    return {"built": True, "path": str(destination.resolve()),
            "tiles": len(thumbnails), "columns": columns}


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("query", nargs="?", help="Search text; Russian works on Pexels")
    parser.add_argument("--kind", default="video",
                        choices=["video", "photo", "illustration", "music", "audio"])
    parser.add_argument("--providers", default="all",
                        help="Comma-separated subset: pexels,pixabay,openverse,jamendo")
    parser.add_argument("--orientation", default="portrait",
                        choices=["portrait", "landscape", "square", "any"])
    parser.add_argument("--count", type=int, default=12)
    parser.add_argument("--min-pixels", type=int, default=1280 * 720,
                        help="Reject anything smaller than this many pixels")
    parser.add_argument("--duration", help="Preferred length range, e.g. 4-12")
    parser.add_argument("--out", default="project/assets/stock",
                        help="Where downloads and the manifest go")
    parser.add_argument("--sheet", help="Write a contact sheet of candidates here")
    parser.add_argument("--ids", help="Download exactly these, as provider:id,provider:id")
    parser.add_argument("--status", action="store_true", help="Show provider configuration")
    args = parser.parse_args()

    if args.status:
        emit({
            "providers": [
                {"id": provider.id, "kinds": list(provider.kinds),
                 "configured": bool(not provider.env or optional_secret(provider.env)),
                 "env": provider.env or "(ключ не нужен)",
                 "license": provider.licence, "signup": provider.signup,
                 "notes": provider.notes}
                for provider in PROVIDERS
            ],
            "how_to_add": "Ключи кладутся в .env в корне скилла.",
        })
        return

    if not args.query:
        parser.error("query is required unless --status is used")

    wanted = (list(SEARCHERS) if args.providers == "all"
              else [name.strip() for name in args.providers.split(",") if name.strip()])
    unknown = [name for name in wanted if name not in SEARCHERS]
    if unknown:
        raise SystemExit(f"Unknown providers: {', '.join(unknown)}")

    duration_range = None
    if args.duration:
        low, _, high = args.duration.partition("-")
        duration_range = (float(low), float(high or low))

    results: list[dict] = []
    notes: list[str] = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(wanted)) as pool:
        futures = {
            pool.submit(SEARCHERS[name], args.query, args.kind, args.orientation, args.count): name
            for name in wanted
        }
        for future in concurrent.futures.as_completed(futures):
            name = futures[future]
            try:
                batch = future.result()
            except Exception as exc:
                notes.append(f"{name}: запрос не удался — {exc}")
                continue
            for item in batch:
                if "__skipped__" in item:
                    notes.append(f"{name}: {item['__skipped__']}")
                elif "__error__" in item:
                    notes.append(f"{name}: {item['__error__']}")
                else:
                    results.append(item)

    if not results:
        emit({"status": "nothing_found", "query": args.query, "kind": args.kind,
              "providers": wanted, "notes": notes})
        raise SystemExit(1)

    results, duplicates = deduplicate(results)
    for item in results:
        item["score"] = round(score(item, args.orientation, args.min_pixels, duration_range), 2)
        licence, licence_url = LICENCES.get(item["provider"], ("unknown", ""))
        item.setdefault("license", licence)
        item.setdefault("license_url", licence_url)
    results.sort(key=lambda item: -item["score"])
    shortlist = results[:args.count]

    out_dir = Path(args.out) / args.kind
    sheet_info = None
    if args.sheet:
        sheet_info = contact_sheet(shortlist, Path(args.sheet))

    downloaded: list[dict] = []
    if args.ids:
        wanted_ids = set()
        for token in args.ids.split(","):
            token = token.strip()
            if not token:
                continue
            provider, _, identifier = token.partition(":")
            if not identifier:
                raise SystemExit(
                    f"--ids expects provider:id pairs, got '{token}'. "
                    f"Example: pexels:35160268,pixabay:68978"
                )
            wanted_ids.add((provider.strip(), identifier.strip()))
        by_key = {(item["provider"], str(item["id"])): item for item in results}
        missing = [key for key in wanted_ids if key not in by_key]
        if missing:
            raise SystemExit(
                "These ids are not in the current result set: "
                + ", ".join(f"{p}:{i}" for p, i in missing)
                + ". Re-run the same search first — ids are only valid for it."
            )
        extension = {"video": ".mp4", "photo": ".jpg", "illustration": ".png",
                     "music": ".mp3", "audio": ".mp3"}[args.kind]
        for key in sorted(wanted_ids):
            item = by_key[key]
            destination = out_dir / f"{item['provider']}_{item['id']}{extension}"
            size = download(item["download"], destination)
            digest = hashlib.sha256(destination.read_bytes()).hexdigest()
            record = {
                "provider": item["provider"],
                "id": item["id"],
                "kind": args.kind,
                "local_path": str(destination),
                "bytes": size,
                "sha256": digest,
                "width": item.get("width"),
                "height": item.get("height"),
                "duration": item.get("duration"),
                "creator": item.get("creator"),
                "creator_url": item.get("creator_url"),
                "source_page": item.get("source_page"),
                "license": item.get("license"),
                "license_url": item.get("license_url"),
                "attribution": item.get("attribution") or (
                    f"{args.kind.title()} by {item.get('creator')} on "
                    f"{item['provider'].title()}"
                ),
                "query": args.query,
                "downloaded_at": datetime.now(timezone.utc).isoformat(),
            }
            if args.kind in ("video", "music", "audio"):
                probe = subprocess.run(
                    ["ffprobe", "-v", "error", "-of", "json", "-show_format", "-show_streams",
                     str(destination)],
                    capture_output=True, text=True, errors="replace",
                )
                if probe.returncode == 0:
                    payload = json.loads(probe.stdout or "{}")
                    fmt = payload.get("format", {})
                    record["verified_duration"] = float(fmt.get("duration") or 0) or None
                    record["verified_streams"] = len(payload.get("streams", []))
                else:
                    record["verification_warning"] = "ffprobe не смог прочитать файл"
            downloaded.append(record)

        manifest_path = out_dir / "manifest.json"
        merged = merge_manifest(manifest_path, downloaded)
        manifest_path.parent.mkdir(parents=True, exist_ok=True)
        manifest_path.write_text(json.dumps(merged, ensure_ascii=False, indent=2) + "\n",
                                 encoding="utf-8")

    emit({
        "status": "ok",
        "query": args.query,
        "kind": args.kind,
        "orientation": args.orientation,
        "providers_searched": wanted,
        "found": len(results),
        "duplicates_removed": duplicates,
        "shortlist": [
            {"pick": f"{item['provider']}:{item['id']}", "score": item["score"],
             "size": f"{item.get('width')}x{item.get('height')}",
             "duration": item.get("duration"), "creator": item.get("creator"),
             "license": item.get("license"), "page": item.get("source_page"),
             "tags": (item.get("tags") or "")[:70]}
            for item in shortlist
        ],
        "contact_sheet": sheet_info,
        "downloaded": downloaded,
        "manifest": str((out_dir / "manifest.json").resolve()) if downloaded else None,
        "notes": notes,
        "next": (
            "Посмотри контактный лист, затем скачай выбранное: "
            f"--ids pexels:ID,pixabay:ID" if not downloaded else
            "Проверь manifest.json — там автор, лицензия и SHA-256 каждого файла."
        ),
    })


if __name__ == "__main__":
    main()
