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

import datetime as dt
import hashlib
import json
import os
from pathlib import Path
import stat
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from zoneinfo import ZoneInfo

ROOT = Path('/data/video-pipeline/HaTramAudio/project/016-Nguoi-Giu-Am-Thanh-Cuoi-Cung')
PROJECT = ROOT.name
RUN = 'run-20260720T233725Z-24504f17'
CANON = 'fae6fce617a14dc4f8b3bd52968cc688a456c45a60bc2ed7701ccd8164875e02'
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'
ENV_FILE = Path.home() / '.config/ha-tram-audio/postiz.env'
FB_EXPECTED = 'cmrijlulx000jj7c8jo4ybyn6'
YT_EXPECTED = 'cmrhxbsdv000bj7c868iio3c7'
TARGET_NAME = 'Hạ Trâm Audio'
SLOTS_EXPECTED = ['09:20', '19:00']


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


def sha(path: Path) -> str:
    h = hashlib.sha256()
    with path.open('rb') as f:
        while block := f.read(8 * 1024 * 1024):
            h.update(block)
    return h.hexdigest()


def atomic(path: Path, obj: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + '.tmp')
    tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
    os.replace(tmp, path)


def redact(text: str, key: str) -> str:
    return text.replace(key, '[REDACTED]') if key else text


def load_env() -> dict[str, str]:
    required = ['POSTIZ_URL', 'POSTIZ_BASE_URL', 'POSTIZ_API_KEY', 'POSTIZ_FACEBOOK_INTEGRATION_ID', 'POSTIZ_YOUTUBE_INTEGRATION_ID', 'POSTIZ_TIMEZONE', 'POSTIZ_SLOTS']
    values = {k: os.environ.get(k, '') for k in required}
    if any(not values[k] for k in required):
        if not ENV_FILE.exists():
            raise RuntimeError('Postiz config file missing')
        if stat.S_IMODE(ENV_FILE.stat().st_mode) != 0o600:
            raise RuntimeError('Postiz config permissions must be 0600')
        for raw in ENV_FILE.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)
            key = key.strip()
            value = value.strip().strip('"').strip("'")
            if key in values and not values[key]:
                values[key] = value
    missing = [k for k in required if not values[k]]
    if missing:
        raise RuntimeError('Postiz config missing variables: ' + ', '.join(missing))
    return values


def api(base: str, key: str, method: str, path: str, payload: dict | None = None, retries: int = 1):
    url = base.rstrip('/') + path
    data = None if payload is None else json.dumps(payload, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
    headers = {'Authorization': key}
    if data is not None:
        headers['Content-Type'] = 'application/json'
    for attempt in range(retries):
        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=120) as response:
                body = response.read().decode('utf-8', errors='replace')
                return json.loads(body) if body.strip() else {}
        except urllib.error.HTTPError as exc:
            body = redact(exc.read().decode('utf-8', errors='replace'), key)
            raise RuntimeError(f'Postiz HTTP {exc.code}: {body[:1500]}') from None
        except (urllib.error.URLError, TimeoutError) as exc:
            if method == 'GET' and attempt + 1 < retries:
                time.sleep(2 ** attempt)
                continue
            raise RuntimeError(f'Postiz transport error during {method}: {type(exc).__name__}') from None


def unwrap_list(value, keys: tuple[str, ...]) -> list:
    if isinstance(value, list):
        return value
    if isinstance(value, dict):
        for key in keys:
            item = value.get(key)
            if isinstance(item, list):
                return item
            if isinstance(item, dict):
                for nested in ('items', 'data', 'posts', 'integrations'):
                    if isinstance(item.get(nested), list):
                        return item[nested]
    return []


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


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


def post_date(post: dict) -> dt.datetime | None:
    value = post.get('publishDate') or post.get('date')
    if not isinstance(value, str):
        return None
    try:
        return parse_date(value)
    except Exception:
        return None


def calendar(base: str, key: str, start: dt.datetime, end: dt.datetime) -> list:
    query = urllib.parse.urlencode({
        'startDate': start.astimezone(dt.timezone.utc).isoformat().replace('+00:00', 'Z'),
        'endDate': end.astimezone(dt.timezone.utc).isoformat().replace('+00:00', 'Z'),
    })
    raw = api(base, key, 'GET', '/posts?' + query, retries=3)
    return unwrap_list(raw, ('posts', 'data'))


def candidate_slots(current: dt.datetime, tz: ZoneInfo, slots: list[str], days: int = 30):
    local_now = current.astimezone(tz)
    for offset in range(days + 1):
        day = local_now.date() + dt.timedelta(days=offset)
        for value in slots:
            hour, minute = map(int, value.split(':'))
            candidate = dt.datetime.combine(day, dt.time(hour, minute), tzinfo=tz)
            if candidate > local_now:
                yield candidate


def select_slot(posts: list, fb: str, yt: str, current: dt.datetime, tz: ZoneInfo, slots: list[str]):
    target = {fb: [], yt: []}
    ignored = 0
    for post in posts:
        iid = post_integration_id(post)
        if iid not in target:
            ignored += 1
            continue
        state = str(post.get('state') or post.get('status') or '').upper()
        when = post_date(post)
        if state in ('QUEUE', 'PUBLISHED') and when is not None:
            target[iid].append(when)
    for local in candidate_slots(current, tz, slots):
        utc = local.astimezone(dt.timezone.utc)
        counts = {iid: sum(abs((value - utc).total_seconds()) < 1 for value in dates) for iid, dates in target.items()}
        if counts[fb] == 0 and counts[yt] == 0:
            return local, utc, counts, ignored
    raise RuntimeError('No common Postiz slot found in 30-day window')


def normalize_media(raw) -> dict:
    value = raw.get('data', raw) if isinstance(raw, dict) else raw
    if isinstance(value, list):
        value = value[0] if value else {}
    if isinstance(value, dict) and isinstance(value.get('data'), dict):
        value = value['data']
    if not isinstance(value, dict) or not value.get('id') or not value.get('path'):
        raise RuntimeError('Postiz upload response missing media id/path')
    return {'id': value['id'], 'path': value['path']}


def upload(base: str, key: str, path: Path, receipt_path: Path, canon: str) -> dict:
    local_sha = sha(path)
    if receipt_path.exists():
        old = json.loads(receipt_path.read_text(encoding='utf-8'))
        if old.get('status') == 'completed' and old.get('verified') and old.get('source_canon_sha256') == canon and old.get('local_sha256') == local_sha and old.get('local_bytes') == path.stat().st_size:
            media = old.get('media') or {}
            if media.get('id') and media.get('path'):
                return media
    attempt = {
        'schema_version': 1, 'project_id': PROJECT, 'run_id': RUN,
        'status': 'uploading', 'verified': False, 'source_canon_sha256': canon,
        'local_path': str(path), 'local_sha256': local_sha, 'local_bytes': path.stat().st_size,
        'credential': '[REDACTED]', 'created_at': now(),
    }
    atomic(receipt_path, attempt)
    def esc(value: str) -> str:
        return value.replace('\\', '\\\\').replace('"', '\\"')
    auth_header = 'Authorization: ' + key
    config = '\n'.join([
        f'url = "{esc(base.rstrip("/") + "/upload")}"',
        f'header = "{esc(auth_header)}"',
        f'form = "file=@{esc(str(path))}"',
        'fail-with-body', 'silent', 'show-error', 'max-time = 7200',
    ]) + '\n'
    result = subprocess.run(['curl', '--config', '-'], input=config, text=True, capture_output=True, timeout=7260)
    if result.returncode != 0:
        attempt.update(status='failed', error=redact((result.stderr or result.stdout)[-1500:], key), updated_at=now())
        atomic(receipt_path, attempt)
        raise RuntimeError('Postiz media upload failed')
    try:
        media = normalize_media(json.loads(result.stdout))
    except Exception as exc:
        attempt.update(status='failed', error=redact(str(exc), key), updated_at=now())
        atomic(receipt_path, attempt)
        raise
    attempt.update(status='completed', verified=True, media=media, updated_at=now())
    atomic(receipt_path, attempt)
    return media


def matching_at(posts: list, fb: str, yt: str, slot: dt.datetime) -> dict[str, list[dict]]:
    found = {fb: [], yt: []}
    for post in posts:
        iid = post_integration_id(post)
        when = post_date(post)
        state = str(post.get('state') or post.get('status') or '').upper()
        if iid in found and when is not None and abs((when - slot).total_seconds()) < 1 and state == 'QUEUE':
            found[iid].append(post)
    return found


def slim_post(post: dict, iid: str) -> dict:
    return {
        'id': post.get('id') or post.get('_id'),
        'integration_id': iid,
        'state': str(post.get('state') or post.get('status') or '').upper(),
        'publish_date': post.get('publishDate') or post.get('date'),
    }


def guard() -> dict:
    lock = json.loads((ROOT / '.ownership-lock.json').read_text(encoding='utf-8'))
    if (lock.get('project_id'), lock.get('run_id'), lock.get('owner'), lock.get('status')) != (PROJECT, RUN, 'zoro', 'main_session_pipeline'):
        raise RuntimeError('Ownership mismatch')
    ready = json.loads(READY.read_text(encoding='utf-8'))
    if ready.get('status') != 'ready' or ready.get('verified') is not True or ready.get('source_canon_sha256') != CANON:
        raise RuntimeError('Publish-ready gate is not verified')
    if ready.get('upload_copy_sha256') != sha(VIDEO) or ready.get('thumbnail_sha256') != sha(THUMB) or ready.get('metadata_sha256') != sha(INFO):
        raise RuntimeError('Publish artifact drift')
    if not 0 < VIDEO.stat().st_size < 1_000_000_000:
        raise RuntimeError('Upload copy violates hard byte cap')
    return ready


def main() -> None:
    ready = guard()
    env = load_env()
    base = env['POSTIZ_BASE_URL'].rstrip('/')
    key = env['POSTIZ_API_KEY']
    fb = env['POSTIZ_FACEBOOK_INTEGRATION_ID']
    yt = env['POSTIZ_YOUTUBE_INTEGRATION_ID']
    timezone = env['POSTIZ_TIMEZONE']
    slots = [x.strip() for x in env['POSTIZ_SLOTS'].replace(';', ',').split(',') if x.strip()]
    if (fb, yt, timezone, slots) != (FB_EXPECTED, YT_EXPECTED, 'Asia/Ho_Chi_Minh', SLOTS_EXPECTED):
        raise RuntimeError('Postiz target configuration does not match locked Hạ Trâm contract')
    connected = api(base, key, 'GET', '/is-connected', retries=3)
    if not isinstance(connected, dict) or connected.get('connected') is not True:
        raise RuntimeError('Postiz is not connected')
    integrations = unwrap_list(api(base, key, 'GET', '/integrations', retries=3), ('integrations', 'data'))
    by_id = {item.get('id'): item for item in integrations if isinstance(item, dict)}
    for iid, identifier in ((fb, 'facebook'), (yt, 'youtube')):
        item = by_id.get(iid)
        if not item or item.get('identifier') != identifier or item.get('name') != TARGET_NAME or item.get('disabled') is True:
            raise RuntimeError(f'Postiz integration preflight failed for {identifier}')
    tz = ZoneInfo(timezone)
    current = dt.datetime.now(dt.timezone.utc)
    start = current - dt.timedelta(days=1)
    end = current + dt.timedelta(days=31)
    pre_posts = calendar(base, key, start, end)
    pre_local, pre_utc, pre_counts, pre_ignored = select_slot(pre_posts, fb, yt, current, tz, slots)
    find_slot = {
        'facebook': api(base, key, 'GET', '/find-slot/' + urllib.parse.quote(fb), retries=3),
        'youtube': api(base, key, 'GET', '/find-slot/' + urllib.parse.quote(yt), retries=3),
    }
    atomic(ROOT / 'log/postiz-preflight.json', {
        'schema_version': 1, 'project_id': PROJECT, 'run_id': RUN, 'status': 'passed', 'verified': True,
        'credential': '[REDACTED]', 'integration_ids': {'facebook': fb, 'youtube': yt},
        'slot_candidate_local': pre_local.isoformat(), 'slot_candidate_utc': pre_utc.isoformat().replace('+00:00', 'Z'),
        'target_occupancy': {'facebook': pre_counts[fb], 'youtube': pre_counts[yt]},
        'other_integration_posts_ignored': pre_ignored, 'find_slot_reference': find_slot,
        'checked_at': now(),
    })
    video_media = upload(base, key, VIDEO, ROOT / 'log/postiz-upload-video.json', CANON)
    thumb_media = upload(base, key, THUMB, ROOT / 'log/postiz-upload-thumbnail.json', CANON)
    current = dt.datetime.now(dt.timezone.utc)
    posts = calendar(base, key, current - dt.timedelta(days=1), current + dt.timedelta(days=31))
    slot_local, slot_utc, counts, ignored = select_slot(posts, fb, yt, current, tz, slots)
    info = INFO.read_text(encoding='utf-8').strip()
    title = info.splitlines()[0].strip()
    if not 2 <= len(title) <= 100:
        raise RuntimeError('YouTube title length invalid')
    payload = {
        'type': 'schedule',
        'date': slot_utc.isoformat().replace('+00:00', 'Z'),
        'shortLink': False,
        'tags': [],
        'posts': [
            {'integration': {'id': fb}, 'value': [{'content': info, 'image': [video_media]}], 'settings': {'__type': 'facebook'}},
            {'integration': {'id': yt}, 'value': [{'content': info, 'image': [video_media]}], 'settings': {'__type': 'youtube', 'title': title, 'type': 'public', 'selfDeclaredMadeForKids': 'no', 'thumbnail': thumb_media, 'tags': []}},
        ],
    }
    encoded = json.dumps(payload, ensure_ascii=False, separators=(',', ':'))
    checked = json.loads(encoded)
    if len(checked.get('posts', [])) != 2 or {p['integration']['id'] for p in checked['posts']} != {fb, yt} or checked['posts'][0]['value'][0]['image'][0] != video_media or checked['posts'][1]['value'][0]['image'][0] != video_media:
        raise RuntimeError('Schedule payload validation failed')
    payload_sha = hashlib.sha256(encoded.encode('utf-8')).hexdigest()
    identity = '|'.join([PROJECT, CANON, RUN, ready['upload_copy_sha256'], ready['thumbnail_sha256'], fb, yt])
    idempotency_key = hashlib.sha256(identity.encode('utf-8')).hexdigest()
    intent_path = ROOT / 'log/postiz-schedule-intent.json'
    schedule_path = ROOT / 'log/postiz-schedule.json'
    if schedule_path.exists():
        old = json.loads(schedule_path.read_text(encoding='utf-8'))
        if old.get('verified') and old.get('idempotency_key') == idempotency_key:
            print(json.dumps({'status': 'scheduled_verified_reused', 'slot_local': old['slot_local'], 'post_ids': old['post_ids']}))
            return
    if intent_path.exists():
        old = json.loads(intent_path.read_text(encoding='utf-8'))
        if old.get('status') in ('creating', 'ambiguous', 'partial_failure'):
            raise RuntimeError('Existing non-terminal schedule intent requires reconciliation')
    intent = {
        'schema_version': 1, 'project_id': PROJECT, 'run_id': RUN, 'status': 'creating', 'verified': False,
        'credential': '[REDACTED]', 'idempotency_key': idempotency_key, 'payload_sha256': payload_sha,
        'source_canon_sha256': CANON, 'video_sha256': ready['upload_copy_sha256'], 'thumbnail_sha256': ready['thumbnail_sha256'],
        'video_media': video_media, 'thumbnail_media': thumb_media,
        'slot_local': slot_local.isoformat(), 'slot_utc': slot_utc.isoformat().replace('+00:00', 'Z'),
        'integration_ids': {'facebook': fb, 'youtube': yt},
        'target_occupancy_before_create': {'facebook': counts[fb], 'youtube': counts[yt]},
        'other_integration_posts_ignored': ignored, 'created_at': now(),
    }
    atomic(intent_path, intent)
    try:
        response = api(base, key, 'POST', '/posts', payload=payload, retries=1)
        intent.update(status='created_response_received', response=response, updated_at=now())
        atomic(intent_path, intent)
    except Exception as exc:
        intent.update(status='ambiguous', error=redact(str(exc), key), updated_at=now())
        atomic(intent_path, intent)
        response = None
    found = None
    for _ in range(36):
        readback = calendar(base, key, slot_utc - dt.timedelta(minutes=5), slot_utc + dt.timedelta(minutes=5))
        found = matching_at(readback, fb, yt, slot_utc)
        if len(found[fb]) == 1 and len(found[yt]) == 1:
            break
        if len(found[fb]) > 1 or len(found[yt]) > 1 or (len(found[fb]) == 1) != (len(found[yt]) == 1):
            break
        time.sleep(5)
    assert found is not None
    if len(found[fb]) != 1 or len(found[yt]) != 1:
        state = 'partial_failure' if bool(found[fb]) != bool(found[yt]) else 'ambiguous'
        intent.update(status=state, observed={'facebook': [slim_post(p, fb) for p in found[fb]], 'youtube': [slim_post(p, yt) for p in found[yt]]}, updated_at=now())
        atomic(intent_path, intent)
        raise RuntimeError('Postiz schedule reconciliation did not find exactly one QUEUE per integration')
    posts_out = {'facebook': slim_post(found[fb][0], fb), 'youtube': slim_post(found[yt][0], yt)}
    if not posts_out['facebook']['id'] or not posts_out['youtube']['id']:
        raise RuntimeError('Calendar read-back missing post IDs')
    post_ids = [posts_out['facebook']['id'], posts_out['youtube']['id']]
    schedule = {
        'schema_version': 1, 'project_id': PROJECT, 'run_id': RUN, 'status': 'scheduled_verified',
        'verified': True, 'scheduled': True, 'published': False, 'credential': '[REDACTED]',
        'idempotency_key': idempotency_key, 'payload_sha256': payload_sha, 'source_canon_sha256': CANON,
        'video_sha256': ready['upload_copy_sha256'], 'video_bytes': ready['upload_copy_bytes'],
        'thumbnail_sha256': ready['thumbnail_sha256'], 'video_media': video_media, 'thumbnail_media': thumb_media,
        'slot_local': slot_local.isoformat(), 'slot_utc': slot_utc.isoformat().replace('+00:00', 'Z'),
        'posts': posts_out, 'post_ids': post_ids,
        'integration_ids': {'facebook': fb, 'youtube': yt},
        'states': {'facebook': 'QUEUE', 'youtube': 'QUEUE'},
        'other_integration_posts_ignored': ignored, 'verified_at': now(),
    }
    atomic(schedule_path, schedule)
    intent.update(status='scheduled_verified', verified=True, post_ids=post_ids, updated_at=now())
    atomic(intent_path, intent)
    print(json.dumps({'status': 'scheduled_verified', 'slot_local': schedule['slot_local'], 'slot_utc': schedule['slot_utc'], 'post_ids': post_ids, 'states': schedule['states']}))


if __name__ == '__main__':
    main()
