"""Upload-only YouTube safety policy, package gates, and audit logging."""

from __future__ import annotations

from dataclasses import asdict, dataclass, fields, replace
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import re
from typing import Any, Iterable, Mapping, Sequence

ALLOWED_UPLOAD_SCOPE = "https://www.googleapis.com/auth/youtube.upload"
BROAD_YOUTUBE_SCOPES = frozenset(
    {
        "https://www.googleapis.com/auth/youtube.readonly",
        "https://www.googleapis.com/auth/youtube",
        "https://www.googleapis.com/auth/youtube.force-ssl",
        "https://www.googleapis.com/auth/youtubepartner",
        "https://www.googleapis.com/auth/yt-analytics.readonly",
        "https://www.googleapis.com/auth/yt-analytics-monetary.readonly",
    }
)
YOUTUBE_RAW_COMMENT_TEXT_ALLOWED = False
ALLOWED_ENDPOINTS = frozenset({"videos.insert", "thumbnails.set"})
BLOCKED_ENDPOINTS = frozenset(
    {
        "commentThreads.list",
        "comments.list",
        "comments.insert",
        "comments.update",
        "comments.delete",
        "commentThreads.update",
        "search.list",
        "activities.list",
        "subscriptions.list",
        "playlists.list",
        "analytics.reports.query",
        "youtubeAnalytics.reports.query",
    }
)
TOKEN_PATH = Path.home() / ".hermes/secrets/youtube/youtube_upload_token.json"
CLIENT_SECRET_PATH = Path.home() / ".hermes/secrets/youtube/youtube_upload_client_secret.json"
PACKAGE_DIR_ENV = "AUTOSHORTS_YOUTUBE_PACKAGE_DIR"
_APPROVAL_RE = re.compile(
    r"^APPROVED_FOR_PRIVATE_YOUTUBE_UPLOAD\s+(?P<candidate_id>\S+)\s+(?P<version>\S+)\s+(?P<video_sha256>[a-fA-F0-9]{64})\s+(?P<posting_pack_sha256>[a-fA-F0-9]{64})$"
)


@dataclass(frozen=True)
class ScopeValidationResult:
    is_safe: bool
    granted_scopes: tuple[str, ...]
    forbidden_scopes: tuple[str, ...]
    message: str


@dataclass(frozen=True)
class EndpointDecision:
    allowed: bool
    endpoint: str
    blocked_reason: str = ""


@dataclass(frozen=True)
class GateResult:
    is_valid: bool
    errors: tuple[str, ...]


@dataclass(frozen=True)
class ParsedApprovalCommand:
    candidate_id: str
    version: str
    video_sha256: str
    posting_pack_sha256: str


@dataclass(frozen=True)
class YouTubePrivateUploadPackage:
    candidate_id: str
    version: str
    video_path: str
    video_sha256: str
    thumbnail_path: str | None
    thumbnail_sha256: str | None
    title: str
    description: str
    tags: tuple[str, ...]
    hashtags: tuple[str, ...]
    categoryId: str
    privacyStatus: str
    selfDeclaredMadeForKids: bool | None
    containsSyntheticMedia: bool | None
    language: str
    synthetic_media_reason: str
    made_for_kids_reason: str
    claim_gate_result: str
    safety_gate_result: str
    upload_allowed: bool
    approval_command: str
    posting_pack_sha256: str
    publishAt: str | None = None

    def with_updates(self, **updates: Any) -> "YouTubePrivateUploadPackage":
        return replace(self, **updates)

    def canonical_payload(self) -> dict[str, Any]:
        payload = asdict(self)
        payload["tags"] = list(self.tags)
        payload["hashtags"] = list(self.hashtags)
        payload["approval_command"] = ""
        payload["posting_pack_sha256"] = ""
        payload["upload_allowed"] = False
        return payload

    def to_json(self) -> str:
        return json.dumps(asdict(self), indent=2, sort_keys=True, ensure_ascii=False) + "\n"

    @classmethod
    def from_json(cls, text: str) -> "YouTubePrivateUploadPackage":
        data = json.loads(text)
        data["tags"] = tuple(data.get("tags") or ())
        data["hashtags"] = tuple(data.get("hashtags") or ())
        allowed = {field.name for field in fields(cls)}
        # Render packages may carry extra audit metadata (for example music_bed).
        # Upload validation must ignore unknown metadata rather than weakening the
        # hash-bound approval fields that are part of this dataclass.
        data = {key: value for key, value in data.items() if key in allowed}
        return cls(**data)


@dataclass(frozen=True)
class YouTubeUploadAuditLog:
    candidate_id: str
    version: str
    video_sha256: str
    thumbnail_sha256: str | None
    posting_pack_sha256: str
    youtube_video_id: str
    privacyStatus: str
    containsSyntheticMedia: bool
    selfDeclaredMadeForKids: bool
    title: str
    description_hash: str
    tags: tuple[str, ...]
    upload_time: str
    oauth_scopes_used: tuple[str, ...]
    api_endpoints_called: tuple[str, ...]
    upload_result: Mapping[str, Any]
    thumbnail_result: Mapping[str, Any] | None
    studio_link: str
    manual_review_required: bool
    public_publish_allowed: bool


class RealYouTubeUploadClient:
    """Tiny adapter around googleapiclient, imported lazily to avoid read scopes or test dependencies."""

    def __init__(self, token_path: Path = TOKEN_PATH):
        try:
            from google.oauth2.credentials import Credentials  # type: ignore
            from googleapiclient.discovery import build  # type: ignore
            from googleapiclient.http import MediaFileUpload  # type: ignore
        except Exception as exc:  # pragma: no cover - dependency/environment dependent
            raise RuntimeError("google-api-python-client/google-auth are required for real upload") from exc
        creds = Credentials.from_authorized_user_file(str(token_path), [ALLOWED_UPLOAD_SCOPE])
        self._service = build("youtube", "v3", credentials=creds)
        self._MediaFileUpload = MediaFileUpload

    def videos_insert(self, *, body: Mapping[str, Any], media_path: str, notifySubscribers: bool) -> Mapping[str, Any]:
        media = self._MediaFileUpload(media_path, chunksize=-1, resumable=True)
        return (
            self._service.videos()
            .insert(part="snippet,status", body=body, media_body=media, notifySubscribers=notifySubscribers)
            .execute()
        )

    def thumbnails_set(self, *, video_id: str, media_path: str) -> Mapping[str, Any]:
        media = self._MediaFileUpload(media_path, chunksize=-1, resumable=True)
        return self._service.thumbnails().set(videoId=video_id, media_body=media).execute()


def _sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def _sha256_text(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


def normalize_scopes(scopes: Iterable[str] | str | None) -> tuple[str, ...]:
    if scopes is None:
        return ()
    if isinstance(scopes, str):
        scopes = scopes.split()
    return tuple(sorted(str(scope).strip() for scope in scopes if str(scope).strip()))


def validate_upload_token_scopes(scopes: Iterable[str] | str | None) -> ScopeValidationResult:
    granted = normalize_scopes(scopes)
    forbidden = tuple(scope for scope in granted if scope in BROAD_YOUTUBE_SCOPES)
    youtube_scopes = tuple(scope for scope in granted if "googleapis.com/auth/youtube" in scope or "googleapis.com/auth/yt-analytics" in scope)
    missing_upload = ALLOWED_UPLOAD_SCOPE not in granted
    extra_youtube = tuple(scope for scope in youtube_scopes if scope != ALLOWED_UPLOAD_SCOPE)
    unsafe = bool(forbidden or missing_upload or extra_youtube)
    if unsafe:
        return ScopeValidationResult(False, granted, tuple(sorted(set(forbidden + extra_youtube))), "Token unsafe for upload-only YouTube mode")
    return ScopeValidationResult(True, granted, (), "Token safe: youtube.upload only")


def load_token_scopes(token_path: Path = TOKEN_PATH) -> ScopeValidationResult:
    data = json.loads(token_path.read_text(encoding="utf-8"))
    scopes = data.get("scopes", data.get("scope", ()))
    return validate_upload_token_scopes(scopes)


def warn_if_youtube_api_key_present(env: Mapping[str, str] | None = None) -> str | None:
    env = env or os.environ
    for key in ("YOUTUBE_API_KEY", "GOOGLE_API_KEY"):
        if env.get(key):
            return f"Warning: {key} detected but ignored; OAuth upload-only is mandatory."
    return None


def enforce_upload_endpoint_allowed(endpoint: str) -> EndpointDecision:
    if endpoint in ("comments.list", "commentThreads.list", "comments.insert", "comments.update", "comments.delete", "commentThreads.update"):
        return EndpointDecision(False, endpoint, "Raw YouTube comments are forbidden input.")
    if endpoint in ALLOWED_ENDPOINTS:
        return EndpointDecision(True, endpoint)
    return EndpointDecision(False, endpoint, f"Endpoint {endpoint} is not in YouTube upload-only allowlist.")


def _compute_posting_hash(payload: dict[str, Any]) -> str:
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
    return _sha256_text(canonical)


def build_private_upload_package(
    *,
    candidate_id: str,
    version: str,
    video_path: str | Path,
    thumbnail_path: str | Path | None,
    title: str,
    description: str,
    tags: Sequence[str],
    hashtags: Sequence[str],
    categoryId: str = "27",
    selfDeclaredMadeForKids: bool | None,
    containsSyntheticMedia: bool | None,
    language: str = "en",
    synthetic_media_reason: str,
    made_for_kids_reason: str,
    claim_gate_result: str,
    safety_gate_result: str,
) -> YouTubePrivateUploadPackage:
    video = Path(video_path)
    thumb = Path(thumbnail_path) if thumbnail_path else None
    video_sha = _sha256_file(video) if video.exists() else ""
    thumb_sha = _sha256_file(thumb) if thumb and thumb.exists() else None
    base = YouTubePrivateUploadPackage(
        candidate_id=candidate_id,
        version=version,
        video_path=str(video),
        video_sha256=video_sha,
        thumbnail_path=str(thumb) if thumb else None,
        thumbnail_sha256=thumb_sha,
        title=title,
        description=description,
        tags=tuple(tags),
        hashtags=tuple(hashtags),
        categoryId=categoryId,
        privacyStatus="private",
        selfDeclaredMadeForKids=selfDeclaredMadeForKids,
        containsSyntheticMedia=containsSyntheticMedia,
        language=language,
        synthetic_media_reason=synthetic_media_reason,
        made_for_kids_reason=made_for_kids_reason,
        claim_gate_result=claim_gate_result,
        safety_gate_result=safety_gate_result,
        upload_allowed=False,
        approval_command="",
        posting_pack_sha256="",
        publishAt=None,
    )
    pack_hash = _compute_posting_hash(base.canonical_payload())
    approval = f"APPROVED_FOR_PRIVATE_YOUTUBE_UPLOAD {candidate_id} {version} {video_sha} {pack_hash}"
    return replace(base, approval_command=approval, posting_pack_sha256=pack_hash)


def parse_approval_command(command: str) -> ParsedApprovalCommand | None:
    match = _APPROVAL_RE.match((command or "").strip())
    if not match:
        return None
    return ParsedApprovalCommand(**match.groupdict())


def validate_private_upload_gate(package: YouTubePrivateUploadPackage, approval_command: str, *, external_triggered: bool = False, comment_triggered: bool = False, context_contains_raw_comment_text: bool = False) -> GateResult:
    errors: list[str] = []
    parsed = parse_approval_command(approval_command)
    if parsed is None:
        errors.append("approval command is missing or invalid")
    else:
        if parsed.candidate_id != package.candidate_id:
            errors.append("candidate_id mismatch")
        if parsed.version != package.version:
            errors.append("version mismatch")
        if parsed.video_sha256 != package.video_sha256:
            errors.append("approval video_sha256 mismatch")
        if parsed.posting_pack_sha256 != package.posting_pack_sha256:
            errors.append("approval posting_pack_sha256 mismatch")

    video = Path(package.video_path)
    if not video.exists():
        errors.append("video file does not exist")
    elif _sha256_file(video) != package.video_sha256:
        errors.append("video_sha256 mismatch")
    if package.thumbnail_path:
        thumb = Path(package.thumbnail_path)
        if not thumb.exists():
            errors.append("thumbnail file does not exist")
        elif package.thumbnail_sha256 and _sha256_file(thumb) != package.thumbnail_sha256:
            errors.append("thumbnail_sha256 mismatch")
    if package.privacyStatus == "public":
        errors.append("privacyStatus=public is blocked")
    elif package.privacyStatus == "unlisted":
        errors.append("privacyStatus=unlisted is blocked")
    elif package.privacyStatus != "private":
        errors.append("privacyStatus must be private")
    if package.publishAt:
        errors.append("publishAt must be absent")
    if package.containsSyntheticMedia is None:
        errors.append("containsSyntheticMedia is required")
    if package.selfDeclaredMadeForKids is None:
        errors.append("selfDeclaredMadeForKids is required")
    if package.claim_gate_result != "valid":
        errors.append("claim gate must be valid")
    if package.safety_gate_result != "valid":
        errors.append("safety gate must be valid")
    if not package.synthetic_media_reason.strip():
        errors.append("synthetic media decision must be valid")
    if not package.made_for_kids_reason.strip():
        errors.append("made-for-kids decision must be valid")
    if context_contains_raw_comment_text:
        errors.append("raw YouTube comments are forbidden input")
    if comment_triggered:
        errors.append("comment-triggered action is blocked")
    if external_triggered:
        errors.append("external-text-triggered action is blocked")
    return GateResult(not errors, tuple(errors))


def build_youtube_video_body(package: YouTubePrivateUploadPackage) -> dict[str, Any]:
    return {
        "snippet": {
            "title": package.title,
            "description": package.description,
            "tags": list(package.tags),
            "categoryId": package.categoryId,
            "defaultLanguage": package.language,
        },
        "status": {
            "privacyStatus": "private",
            "selfDeclaredMadeForKids": package.selfDeclaredMadeForKids,
            "containsSyntheticMedia": package.containsSyntheticMedia,
        },
    }


def execute_private_upload(
    package: YouTubePrivateUploadPackage,
    approval_command: str,
    *,
    youtube_client: Any | None = None,
    token_scopes: Iterable[str] | str | None = None,
    audit_dir: str | Path | None = None,
) -> YouTubeUploadAuditLog:
    scope_result = validate_upload_token_scopes(token_scopes if token_scopes is not None else load_token_scopes().granted_scopes)
    if not scope_result.is_safe:
        raise PermissionError(scope_result.message)
    gate = validate_private_upload_gate(package, approval_command)
    if not gate.is_valid:
        raise PermissionError("; ".join(gate.errors))
    client = youtube_client or RealYouTubeUploadClient()
    endpoints: list[str] = []
    for endpoint in ("videos.insert",):
        decision = enforce_upload_endpoint_allowed(endpoint)
        if not decision.allowed:
            raise PermissionError(decision.blocked_reason)
    body = build_youtube_video_body(package)
    upload_result = client.videos_insert(body=body, media_path=package.video_path, notifySubscribers=False)
    endpoints.append("videos.insert")
    video_id = str(upload_result.get("id") or "")
    thumbnail_result = None
    if package.thumbnail_path:
        decision = enforce_upload_endpoint_allowed("thumbnails.set")
        if not decision.allowed:
            raise PermissionError(decision.blocked_reason)
        thumbnail_result = client.thumbnails_set(video_id=video_id, media_path=package.thumbnail_path)
        endpoints.append("thumbnails.set")
    audit = YouTubeUploadAuditLog(
        candidate_id=package.candidate_id,
        version=package.version,
        video_sha256=package.video_sha256,
        thumbnail_sha256=package.thumbnail_sha256,
        posting_pack_sha256=package.posting_pack_sha256,
        youtube_video_id=video_id,
        privacyStatus="private",
        containsSyntheticMedia=bool(package.containsSyntheticMedia),
        selfDeclaredMadeForKids=bool(package.selfDeclaredMadeForKids),
        title=package.title,
        description_hash=_sha256_text(package.description),
        tags=package.tags,
        upload_time=datetime.now(timezone.utc).isoformat(),
        oauth_scopes_used=normalize_scopes(token_scopes if token_scopes is not None else [ALLOWED_UPLOAD_SCOPE]),
        api_endpoints_called=tuple(endpoints),
        upload_result=dict(upload_result),
        thumbnail_result=dict(thumbnail_result) if thumbnail_result is not None else None,
        studio_link=f"https://studio.youtube.com/video/{video_id}/edit" if video_id else "",
        manual_review_required=True,
        public_publish_allowed=False,
    )
    if audit_dir:
        out = Path(audit_dir)
        out.mkdir(parents=True, exist_ok=True)
        (out / f"{package.candidate_id}_{package.version}_youtube_upload_audit.json").write_text(
            json.dumps(asdict(audit), indent=2, ensure_ascii=False), encoding="utf-8"
        )
    return audit


def package_dir() -> Path:
    return Path(os.environ.get(PACKAGE_DIR_ENV, "data/youtube_upload_packages"))


def package_path_for(candidate_id: str, version: str | None = None) -> Path:
    base = package_dir()
    if version:
        return base / f"{candidate_id}_{version}_youtube_private_upload_package.json"
    direct = base / "youtube_private_upload_package.json"
    if direct.exists():
        return direct
    matches = sorted(base.glob(f"{candidate_id}_*_youtube_private_upload_package.json"))
    return matches[-1] if matches else base / f"{candidate_id}_v1_youtube_private_upload_package.json"


def save_package(package: YouTubePrivateUploadPackage, out_dir: Path | None = None) -> Path:
    old_env = os.environ.get(PACKAGE_DIR_ENV)
    if out_dir is not None:
        base = out_dir
    else:
        base = package_dir()
    base.mkdir(parents=True, exist_ok=True)
    path = base / f"{package.candidate_id}_{package.version}_youtube_private_upload_package.json"
    path.write_text(package.to_json(), encoding="utf-8")
    if old_env and path.name != "youtube_private_upload_package.json":
        # no-op; kept explicit so tests using a generic file remain supported by package_path_for
        pass
    return path
