#!/usr/bin/env python3
from __future__ import annotations

import datetime
import hashlib
import json
import os
import subprocess
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path
from zoneinfo import ZoneInfo

ROOT = Path("/data/video-pipeline/HaTramAudio/project/013-Ngay-Buu-Cuc-Cu-Sang-Den")
PROJECT_ID = "013-Ngay-Buu-Cuc-Cu-Sang-Den"
RUN_ID = "run-20260720T110344Z-f6c08be9"
OWNER = "zoro"
CONFIG = Path.home() / ".config" / "ha-tram-audio" / "postiz.env"
FB = "cmrijlulx000jj7c8jo4ybyn6"
YT = "cmrhxbsdv000bj7c868iio3c7"
TZ = ZoneInfo("Asia/Ho_Chi_Minh")
SLOTS = [(9, 20), (19, 0)]
VIDEO = ROOT / "output" / "final-upload.mp4"
THUMB = ROOT / "image" / "normalized" / "intro-poster-1920x1080.png"
INFO = ROOT / "output" / "info.txt"
READY = ROOT / "log" / "publish-ready.json"
LOG = ROOT / "log"


def now() -> str:
    return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z")


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


def guard() -> None:
    lock = json.loads((ROOT / ".ownership-lock.json").read_text(encoding="utf-8"))
    for key, value in {"project_id": PROJECT_ID, "run_id": RUN_ID, "owner": OWNER, "status": "active"}.items():
        if lock.get(key) != value:
            raise RuntimeError(f"ownership mismatch: {key}")


def atomic_json(path: Path, value: dict) -> None:
    guard()
    tmp = path.with_name("." + path.name + ".tmp")
    with tmp.open("w", encoding="utf-8") as f:
        json.dump(value, f, ensure_ascii=False, indent=2)
        f.write("\n")
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, path)


def load_env() -> dict[str, str]:
    values = dict(os.environ)
    for raw in CONFIG.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        values.setdefault(key.strip(), value.strip().strip("\"'"))
    required = ["POSTIZ_BASE_URL", "POSTIZ_API_KEY", "POSTIZ_FACEBOOK_INTEGRATION_ID", "POSTIZ_YOUTUBE_INTEGRATION_ID", "POSTIZ_TIMEZONE", "POSTIZ_SLOTS"]
    missing = [key for key in required if not values.get(key)]
    if missing:
        raise RuntimeError("missing Postiz config variables: " + ", ".join(missing))
    if values["POSTIZ_FACEBOOK_INTEGRATION_ID"] != FB or values["POSTIZ_YOUTUBE_INTEGRATION_ID"] != YT:
        raise RuntimeError("Postiz integration IDs mismatch")
    if values["POSTIZ_TIMEZONE"] != "Asia/Ho_Chi_Minh":
        raise RuntimeError("Postiz timezone mismatch")
    return values


def api_get(base: str, key: str, path: str) -> dict | list:
    request = urllib.request.Request(base.rstrip("/") + path, headers={"Authorization": key, "Accept": "application/json"})
    with urllib.request.urlopen(request, timeout=45) as response:
        return json.loads(response.read())


def api_post(base: str, key: str, path: str, payload: dict) -> tuple[int, dict | list]:
    request = urllib.request.Request(
        base.rstrip("/") + path,
        data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": key, "Content-Type": "application/json", "Accept": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=90) as response:
        return response.status, json.loads(response.read())


def parse_posts(value: dict | list) -> list[dict]:
    if isinstance(value, dict):
        posts = value.get("posts", [])
    else:
        posts = value
    return posts if isinstance(posts, list) else []


def post_integration_id(post: dict) -> str | None:
    integration = post.get("integration")
    return integration.get("id") if isinstance(integration, dict) else post.get("integrationId")


def post_date(post: dict) -> str | None:
    return post.get("publishDate") or post.get("date")


def normalized_utc(value: str) -> datetime.datetime:
    if value.endswith("Z"):
        value = value[:-1] + "+00:00"
    parsed = datetime.datetime.fromisoformat(value)
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=datetime.timezone.utc)
    return parsed.astimezone(datetime.timezone.utc)


def calendar(base: str, key: str, start: datetime.datetime, end: datetime.datetime) -> list[dict]:
    params = urllib.parse.urlencode({
        "startDate": start.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
        "endDate": end.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
    })
    return parse_posts(api_get(base, key, "/posts?" + params))


def find_slot(base: str, key: str) -> tuple[datetime.datetime, dict]:
    local_now = datetime.datetime.now(TZ)
    start = local_now.astimezone(datetime.timezone.utc) - datetime.timedelta(minutes=5)
    end = start + datetime.timedelta(days=30)
    posts = calendar(base, key, start, end)
    occupied = {FB: set(), YT: set()}
    other = 0
    for post in posts:
        iid = post_integration_id(post)
        state = str(post.get("state", "")).upper()
        raw_date = post_date(post)
        if iid not in occupied:
            other += 1
            continue
        if state not in {"QUEUE", "PUBLISHED"} or not raw_date:
            continue
        occupied[iid].add(normalized_utc(raw_date))
    candidates = []
    for day_offset in range(31):
        day = (local_now + datetime.timedelta(days=day_offset)).date()
        for hour, minute in SLOTS:
            candidate = datetime.datetime.combine(day, datetime.time(hour, minute), TZ)
            if candidate > local_now:
                candidates.append(candidate)
    for candidate in candidates:
        utc = candidate.astimezone(datetime.timezone.utc)
        if utc not in occupied[FB] and utc not in occupied[YT]:
            return candidate, {"facebook_occupied_count": len(occupied[FB]), "youtube_occupied_count": len(occupied[YT]), "other_posts_ignored": other}
    raise RuntimeError("no common slot found in 31 days")


def upload(base: str, key: str, path: Path) -> dict:
    # Keep the credential out of argv and process listings by passing curl config via stdin.
    cfg = f'url = "{base.rstrip("/")}/upload"\nheader = "Authorization: {key}"\nheader = "Accept: application/json"\nform = "file=@{path}"\nsilent\nshow-error\nfail-with-body\n'
    result = subprocess.run(
        ["curl", "--config", "-"],
        input=cfg,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        timeout=3600,
    )
    if result.returncode != 0:
        raise RuntimeError("Postiz upload failed: " + result.stderr[-800:])
    value = json.loads(result.stdout)
    media = value.get("data", value) if isinstance(value, dict) else value
    if isinstance(media, list):
        media = media[0] if media else {}
    if not isinstance(media, dict) or not media.get("id") or not media.get("path"):
        raise RuntimeError("Postiz upload response missing media id/path")
    return {"id": media["id"], "path": media["path"]}


def parse_info() -> tuple[str, str]:
    text = INFO.read_text(encoding="utf-8").strip()
    title, _, description = text.partition("\n\n")
    if not (2 <= len(title) <= 100) or not description.strip():
        raise RuntimeError("metadata title/description contract failed")
    return title, description.strip()


def response_post_ids(value: dict | list) -> list[str]:
    found: list[str] = []
    def walk(item):
        if isinstance(item, dict):
            if isinstance(item.get("id"), str) and item.get("id").startswith("cm"):
                found.append(item["id"])
            for child in item.values():
                walk(child)
        elif isinstance(item, list):
            for child in item:
                walk(child)
    walk(value)
    return list(dict.fromkeys(found))


def main() -> None:
    guard()
    ready = json.loads(READY.read_text(encoding="utf-8"))
    if ready.get("project_id") != PROJECT_ID or ready.get("run_id") != RUN_ID or ready.get("status") != "ready" or ready.get("verified") is not True:
        raise RuntimeError("publish-ready gate failed")
    artifacts = ready["artifacts"]
    for name, path in {"upload_copy": VIDEO, "metadata": INFO}.items():
        if sha(path) != artifacts[name]["sha256"]:
            raise RuntimeError(f"publish artifact hash mismatch: {name}")
    if VIDEO.stat().st_size >= 1_000_000_000:
        raise RuntimeError("upload copy is not under one billion bytes")
    poster_receipt = json.loads((ROOT / "log" / "poster.json").read_text(encoding="utf-8"))
    thumb_hash = sha(THUMB)
    if poster_receipt.get("normalized_intro_poster_sha256") != thumb_hash or poster_receipt.get("source_canon_sha256") != ready.get("source_canon_sha256"):
        raise RuntimeError("thumbnail gate failed")
    env = load_env()
    base, key = env["POSTIZ_BASE_URL"], env["POSTIZ_API_KEY"]
    connected = api_get(base, key, "/is-connected")
    if not isinstance(connected, dict) or connected.get("connected") is not True:
        raise RuntimeError("Postiz is not connected")
    integrations_raw = api_get(base, key, "/integrations")
    integrations = integrations_raw.get("integrations", integrations_raw) if isinstance(integrations_raw, dict) else integrations_raw
    if not isinstance(integrations, list):
        raise RuntimeError("invalid integrations response")
    by_id = {item.get("id"): item for item in integrations if isinstance(item, dict)}
    for iid in [FB, YT]:
        if iid not in by_id or by_id[iid].get("disabled") is True:
            raise RuntimeError("target integration missing/disabled")
    tentative_slot, tentative_scan = find_slot(base, key)
    atomic_json(LOG / "calendar-preflight.json", {
        "schema_version": 1, "project_id": PROJECT_ID, "run_id": RUN_ID, "status": "preflight_only", "verified": True,
        "tentative_slot_local": tentative_slot.isoformat(), "tentative_slot_utc": tentative_slot.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
        **tentative_scan, "created_at": now(),
    })

    video_hash = sha(VIDEO)
    video_receipt_path = LOG / "postiz-upload-video.json"
    if video_receipt_path.exists():
        video_receipt = json.loads(video_receipt_path.read_text(encoding="utf-8"))
        if video_receipt.get("local_sha256") != video_hash or video_receipt.get("local_bytes") != VIDEO.stat().st_size:
            raise RuntimeError("stale video upload receipt")
        video_media = video_receipt["media"]
    else:
        video_media = upload(base, key, VIDEO)
        atomic_json(video_receipt_path, {
            "schema_version": 1, "project_id": PROJECT_ID, "run_id": RUN_ID, "status": "uploaded", "verified": True,
            "source_canon_sha256": ready["source_canon_sha256"], "local_path": str(VIDEO), "local_sha256": video_hash,
            "local_bytes": VIDEO.stat().st_size, "media": video_media, "uploaded_at": now(),
        })

    thumb_receipt_path = LOG / "postiz-upload-thumbnail.json"
    if thumb_receipt_path.exists():
        thumb_receipt = json.loads(thumb_receipt_path.read_text(encoding="utf-8"))
        if thumb_receipt.get("local_sha256") != thumb_hash or thumb_receipt.get("local_bytes") != THUMB.stat().st_size:
            raise RuntimeError("stale thumbnail upload receipt")
        thumb_media = thumb_receipt["media"]
    else:
        thumb_media = upload(base, key, THUMB)
        atomic_json(thumb_receipt_path, {
            "schema_version": 1, "project_id": PROJECT_ID, "run_id": RUN_ID, "status": "uploaded", "verified": True,
            "source_canon_sha256": ready["source_canon_sha256"], "local_path": str(THUMB), "local_sha256": thumb_hash,
            "local_bytes": THUMB.stat().st_size, "media": thumb_media, "uploaded_at": now(),
        })

    slot, scan = find_slot(base, key)
    slot_utc = slot.astimezone(datetime.timezone.utc).replace(microsecond=0)
    title, description = parse_info()
    payload = {
        "type": "schedule", "date": slot_utc.isoformat().replace("+00:00", "Z"), "shortLink": False, "tags": [],
        "posts": [
            {"integration": {"id": FB}, "value": [{"content": description, "image": [video_media]}], "settings": {"__type": "facebook"}},
            {"integration": {"id": YT}, "value": [{"content": description, "image": [video_media]}], "settings": {"__type": "youtube", "title": title, "type": "public", "selfDeclaredMadeForKids": "no", "thumbnail": thumb_media, "tags": []}},
        ],
    }
    serialized = json.dumps(payload, ensure_ascii=False, sort_keys=True)
    parsed = json.loads(serialized)
    if len(parsed.get("posts", [])) != 2 or {x["integration"]["id"] for x in parsed["posts"]} != {FB, YT}:
        raise RuntimeError("schedule payload integration contract failed")
    idempotency = hashlib.sha256((PROJECT_ID + ready["source_canon_sha256"] + RUN_ID + video_media["id"] + video_hash + thumb_hash + FB + YT).encode()).hexdigest()
    intent_path = LOG / "postiz-schedule-intent.json"
    if intent_path.exists():
        intent = json.loads(intent_path.read_text(encoding="utf-8"))
        if intent.get("idempotency_key") != idempotency:
            raise RuntimeError("foreign/stale schedule intent")
        raise RuntimeError("schedule intent already exists; reconcile calendar before retry")
    atomic_json(intent_path, {
        "schema_version": 1, "project_id": PROJECT_ID, "run_id": RUN_ID, "status": "creating", "verified": False,
        "idempotency_key": idempotency, "source_canon_sha256": ready["source_canon_sha256"],
        "slot_local": slot.isoformat(), "slot_utc": payload["date"], "payload_sha256": hashlib.sha256(serialized.encode()).hexdigest(),
        "video_sha256": video_hash, "thumbnail_sha256": thumb_hash, "video_media_id": video_media["id"], "thumbnail_media_id": thumb_media["id"],
        "calendar_precheck": scan, "created_at": now(),
    })
    status_code, response = api_post(base, key, "/posts", payload)
    post_ids = response_post_ids(response)
    # Calendar, not response shape, is the final authority.
    verify_posts = calendar(base, key, slot_utc - datetime.timedelta(minutes=10), slot_utc + datetime.timedelta(minutes=10))
    matched = []
    for post in verify_posts:
        iid = post_integration_id(post)
        raw_date = post_date(post)
        if iid in {FB, YT} and raw_date and normalized_utc(raw_date) == slot_utc and str(post.get("state", "")).upper() == "QUEUE":
            matched.append({"id": post.get("id"), "integration_id": iid, "state": post.get("state"), "publishDate": raw_date})
    counts = {FB: 0, YT: 0}
    for row in matched:
        counts[row["integration_id"]] += 1
    if counts != {FB: 1, YT: 1}:
        intent = json.loads(intent_path.read_text(encoding="utf-8"))
        intent.update({"status": "partial_failure" if sum(counts.values()) == 1 else "ambiguous", "verified": False, "http_status": status_code, "response_post_ids": post_ids, "calendar_matches": matched, "updated_at": now()})
        atomic_json(intent_path, intent)
        raise RuntimeError("Postiz calendar verification did not find exactly one queued post per integration")
    receipt = {
        "schema_version": 1, "project_id": PROJECT_ID, "run_id": RUN_ID, "status": "scheduled_verified", "verified": True,
        "scheduled": True, "published": False, "source_canon_sha256": ready["source_canon_sha256"],
        "slot_local": slot.isoformat(), "slot_utc": payload["date"], "idempotency_key": idempotency,
        "media": {"video": {**video_media, "local_sha256": video_hash, "local_bytes": VIDEO.stat().st_size}, "thumbnail": {**thumb_media, "local_sha256": thumb_hash}},
        "posts": matched, "calendar_verification": {"exact_one_per_integration": True, "same_timestamp": True, "both_queue": True, **scan},
        "verified_at": now(),
    }
    atomic_json(LOG / "postiz-schedule.json", receipt)
    intent = json.loads(intent_path.read_text(encoding="utf-8"))
    intent.update({"status": "scheduled_verified", "verified": True, "post_ids": {row["integration_id"]: row["id"] for row in matched}, "terminal_at": now()})
    atomic_json(intent_path, intent)
    print(json.dumps({"status": "scheduled_verified", "slot_local": slot.isoformat(), "posts": matched}, ensure_ascii=False))


if __name__ == "__main__":
    main()
