#!/usr/bin/env python3
"""Shared configuration, paths, canvas presets and provider registry.

Every other script imports this instead of re-deriving the skill root, re-reading
`.env`, or hard-coding 720x1280. Secret values are never printed — only whether
they are configured.
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path

try:
    from dotenv import load_dotenv
except ImportError:  # keep the module importable on a bare interpreter
    def load_dotenv(*_args, **_kwargs):  # type: ignore[misc]
        return False


SKILL_ROOT = Path(__file__).resolve().parents[1]
ASSETS = SKILL_ROOT / "assets"
FONT_SOURCES = ASSETS / "fonts"
FONT_PACK = ASSETS / "fonts" / "all"
CAPTION_STYLES = ASSETS / "caption_styles"
TRANSITIONS = ASSETS / "transitions"
SFX = ASSETS / "sfx"
GRADES = ASSETS / "grades"
CACHE = SKILL_ROOT / "cache"
SCHEMA = SKILL_ROOT / "schema"
TEMPLATES = SKILL_ROOT / "templates"


# --------------------------------------------------------------------- canvases

@dataclass(frozen=True)
class Canvas:
    """A delivery format. `safe_*` are the fractions of the frame that stay clear
    of platform UI, measured against the worst case of TikTok/Reels/Shorts
    overlays rather than an idealised title-safe box."""

    id: str
    width: int
    height: int
    fps: int
    safe_top: float
    safe_bottom: float
    safe_side: float
    use: str
    # Band where an overlay card may sit without covering the speaker. In a framed
    # vertical talking head the face occupies roughly the upper third, and the
    # caption zone starts around 0.72 — so a card belongs between them. Putting a
    # card at the top of the frame lands it on the speaker's forehead.
    graphics_top: float = 0.44
    graphics_bottom: float = 0.70

    @property
    def aspect(self) -> float:
        return self.width / self.height

    @property
    def graphics_box(self) -> dict[str, int]:
        """Where an overlay card may be placed without covering the speaker."""
        return {
            "left": round(self.width * self.safe_side),
            "right": round(self.width * (1 - self.safe_side)),
            "top": round(self.height * self.graphics_top),
            "bottom": round(self.height * self.graphics_bottom),
            "height": round(self.height * (self.graphics_bottom - self.graphics_top)),
        }

    @property
    def safe_box(self) -> dict[str, int]:
        return {
            "left": round(self.width * self.safe_side),
            "right": round(self.width * (1 - self.safe_side)),
            "top": round(self.height * self.safe_top),
            "bottom": round(self.height * (1 - self.safe_bottom)),
        }

    def as_dict(self) -> dict:
        return {
            "id": self.id, "width": self.width, "height": self.height, "fps": self.fps,
            "aspect": round(self.aspect, 4), "safe_box": self.safe_box,
            "graphics_box": self.graphics_box, "use": self.use,
        }


CANVASES: dict[str, Canvas] = {
    "reels": Canvas("reels", 1080, 1920, 30, 0.11, 0.20, 0.055,
                    "Reels/Shorts/TikTok. Низ занят подписью и кнопками."),
    "reels_hq": Canvas("reels_hq", 1080, 1920, 60, 0.11, 0.20, 0.055,
                       "То же, но 60 кадров: спорт, мото, быстрые движения."),
    "square": Canvas("square", 1080, 1080, 30, 0.08, 0.14, 0.06,
                     "Лента Instagram/VK, карусели."),
    "portrait_45": Canvas("portrait_45", 1080, 1350, 30, 0.08, 0.16, 0.06,
                          "4:5 — максимальная площадь в ленте."),
    "landscape": Canvas("landscape", 1920, 1080, 30, 0.07, 0.10, 0.05,
                        "YouTube, сайт, презентация.", 0.58, 0.86),
    "landscape_4k": Canvas("landscape_4k", 3840, 2160, 30, 0.07, 0.10, 0.05,
                           "YouTube 4K мастер."),
    "preview": Canvas("preview", 720, 1280, 30, 0.11, 0.20, 0.055,
                      "Быстрый черновой рендер для проверки читаемости."),
}
DEFAULT_CANVAS = "reels"


def canvas(name: str | None = None) -> Canvas:
    key = (name or DEFAULT_CANVAS).strip()
    if key in CANVASES:
        return CANVASES[key]
    if "x" in key:  # accept an explicit "1080x1920" or "1080x1920@60"
        geometry, _, fps = key.partition("@")
        width, _, height = geometry.partition("x")
        try:
            return Canvas("custom", int(width), int(height), int(fps or 30),
                          0.11, 0.20, 0.055, "Пользовательский размер")
        except ValueError:
            pass
    raise SystemExit(f"Unknown canvas '{name}'. Known: {', '.join(CANVASES)} or WIDTHxHEIGHT[@FPS]")


# -------------------------------------------------------------------- loudness

# Platform normalisation targets. Delivering louder than the target does not make
# the video louder — the platform turns it down and the mix loses its dynamics.
LOUDNESS = {
    "reels": {"i": -14.0, "tp": -1.0, "lra": 9.0, "note": "Instagram/TikTok/Shorts"},
    "youtube": {"i": -14.0, "tp": -1.0, "lra": 11.0, "note": "YouTube long form"},
    "broadcast": {"i": -23.0, "tp": -2.0, "lra": 7.0, "note": "EBU R128"},
    "loud": {"i": -12.0, "tp": -1.0, "lra": 7.0, "note": "Плотный ролик без музыки-бэда"},
}
DEFAULT_LOUDNESS = "reels"


# -------------------------------------------------------------------- providers

@dataclass
class Provider:
    id: str
    env: str
    kinds: tuple[str, ...]
    signup: str
    licence: str
    notes: str = ""
    optional: bool = False
    extra_env: tuple[str, ...] = field(default_factory=tuple)


PROVIDERS: tuple[Provider, ...] = (
    Provider("pexels", "PEXELS_API_KEY", ("video", "photo"),
             "https://www.pexels.com/api/",
             "Pexels License — свободно для коммерческого использования, без обязательной атрибуции",
             "Основной источник вертикального видео и фото."),
    Provider("pixabay", "PIXABAY_API_KEY", ("video", "photo", "illustration"),
             "https://pixabay.com/api/docs/",
             "Pixabay Content License",
             "Публичный API отдаёт только изображения и видео. Музыки в API нет."),
    Provider("jamendo", "JAMENDO_CLIENT_ID", ("music",),
             "https://devportal.jamendo.com/",
             "Creative Commons, отдельная лицензия на каждый трек",
             "Единственный музыкальный API в скилле. Требует проверки лицензии трека.",
             optional=True),
    Provider("freesound", "FREESOUND_API_KEY", ("sfx",),
             "https://freesound.org/apiv2/apply/",
             "CC0 / CC-BY — фильтруется запросом",
             "Дополняет локальный синтез SFX реальными записями.",
             optional=True),
    Provider("openverse", "", ("photo", "audio"),
             "https://api.openverse.org/v1/",
             "CC0 / CC-BY / PDM",
             "Работает без ключа, но с жёстким лимитом запросов.",
             optional=True),
)


def load_skill_env(override: bool = False) -> Path:
    env_path = SKILL_ROOT / ".env"
    load_dotenv(env_path, override=override)
    return env_path


def optional_secret(name: str) -> str:
    load_skill_env()
    return os.getenv(name, "").strip()


def require_secret(name: str) -> str:
    value = optional_secret(name)
    if not value:
        provider = next((p for p in PROVIDERS if p.env == name), None)
        hint = f"\nGet one at: {provider.signup}" if provider else ""
        raise SystemExit(f"Missing {name}. Put it in {SKILL_ROOT / '.env'}{hint}")
    return value


def provider_status() -> list[dict]:
    load_skill_env()
    result = []
    for provider in PROVIDERS:
        configured = True if not provider.env else bool(optional_secret(provider.env))
        result.append({
            "id": provider.id,
            "env": provider.env or "(key not required)",
            "configured": configured,
            "kinds": list(provider.kinds),
            "optional": provider.optional,
            "license": provider.licence,
            "signup": provider.signup,
            "notes": provider.notes,
        })
    return result


# ----------------------------------------------------------------------- binaries

def which(name: str) -> str | None:
    return shutil.which(name)


def require_binary(name: str) -> str:
    path = which(name)
    if not path:
        raise SystemExit(f"{name} is not on PATH. Install it before rendering.")
    return path


def ffmpeg_filter_path(path: Path | str) -> str:
    """Escape a filesystem path for use inside an ffmpeg filter argument."""
    text = Path(path).resolve().as_posix()
    return text.replace("\\", "/").replace(":", r"\:").replace("'", r"\'")


def run(command: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess:
    completed = subprocess.run(
        command,
        capture_output=capture,
        text=True,
        errors="replace",
    )
    if check and completed.returncode:
        detail = (completed.stderr or completed.stdout or "").strip()
        raise SystemExit(f"Command failed ({completed.returncode}): {' '.join(command[:6])} ...\n{detail[-2000:]}")
    return completed


def utf8_stdout() -> None:
    """Windows consoles default to cp1251; Cyrillic JSON reports must not crash."""
    for stream in (sys.stdout, sys.stderr):
        if hasattr(stream, "reconfigure"):
            stream.reconfigure(encoding="utf-8", errors="replace")


def emit(payload: dict) -> None:
    utf8_stdout()
    print(json.dumps(payload, ensure_ascii=False, indent=2))


# ------------------------------------------------------------------------- CLI

def paths_report() -> dict:
    entries = {
        "skill_root": SKILL_ROOT,
        "font_sources": FONT_SOURCES,
        "font_pack_flat": FONT_PACK,
        "caption_styles": CAPTION_STYLES,
        "transitions": TRANSITIONS,
        "sfx": SFX,
        "grades": GRADES,
        "cache": CACHE,
        "templates": TEMPLATES,
    }
    return {
        key: {"path": str(value), "exists": value.exists(),
              "files": len(list(value.glob("*"))) if value.is_dir() else None}
        for key, value in entries.items()
    }


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--status", action="store_true", help="Providers, binaries and paths")
    parser.add_argument("--providers", action="store_true", help="Provider registry only")
    parser.add_argument("--canvases", action="store_true", help="Delivery presets")
    parser.add_argument("--paths", action="store_true", help="Resolved asset directories")
    args = parser.parse_args()
    env_path = load_skill_env()
    if args.providers:
        emit({"providers": provider_status()})
        return
    if args.canvases:
        emit({"canvases": {key: value.as_dict() for key, value in CANVASES.items()},
              "loudness": LOUDNESS, "default_canvas": DEFAULT_CANVAS})
        return
    if args.paths:
        emit({"paths": paths_report()})
        return
    emit({
        "env_file": str(env_path),
        "env_exists": env_path.exists(),
        "providers": provider_status(),
        "binaries": {name: which(name) for name in ("ffmpeg", "ffprobe", "node", "npm", "hyperframes")},
        "paths": paths_report(),
        "default_canvas": DEFAULT_CANVAS,
        "default_loudness": DEFAULT_LOUDNESS,
    })


if __name__ == "__main__":
    main()
