#!/usr/bin/env python3
"""Dedicated Postiz host only. Dry-run unless --poweroff and ENABLE_POWEROFF exist.

Requires a sealed daily batch, no concurrent writers/background media work,
and BIOS daily wake validated separately. Never infer completion from elapsed time.
"""
import argparse
import datetime as dt
import hashlib
import json
import pathlib
import subprocess
import sys
import time
import urllib.parse
import urllib.request

UTC = dt.timezone.utc

def parse_time(value):
    parsed = dt.datetime.fromisoformat(value.replace('Z', '+00:00'))
    if parsed.tzinfo is None:
        raise ValueError('Timestamp must include timezone')
    return parsed.astimezone(UTC)

def validate(batch, payload, now):
    """Fail closed on unknown schema, missing IDs, errors or outstanding posts."""
    if batch.get('date') != now.date().isoformat() or batch.get('sealed') is not True:
        raise ValueError('Batch is not sealed for today (UTC)')
    if not dt.time(9, 45) <= now.time().replace(tzinfo=None) < dt.time(10, 0):
        raise ValueError('Outside the 09:45–10:00 UTC shutdown window')
    next_wake = dt.datetime.combine(now.date() + dt.timedelta(days=1), dt.time(8, 45), UTC)
    if parse_time(batch['nextWake']) != next_wake:
        raise ValueError('Next BIOS wake must be tomorrow at 08:45 UTC')
    ids = batch.get('postIds')
    if not isinstance(ids, list) or not ids or any(not isinstance(x, str) or not x for x in ids):
        raise ValueError('Expected post IDs must be a non-empty string list')
    if len(ids) != len(set(ids)):
        raise ValueError('Duplicate expected IDs')
    rows = payload.get('posts')
    if not isinstance(rows, list):
        raise ValueError('Unexpected posts response')
    found = {}
    for row in rows:
        if not isinstance(row, dict) or not isinstance(row.get('id'), str):
            raise ValueError('Malformed post')
        if row['id'] in found:
            raise ValueError('Duplicate API post IDs')
        if row.get('state') not in ('PUBLISHED', 'DRAFT') or row.get('error'):
            raise ValueError('Queued, failed or unknown post state; remain on')
        found[row['id']] = row
    for post_id in ids:
        row = found.get(post_id, {})
        if row.get('state') != 'PUBLISHED' or not row.get('releaseURL'):
            raise ValueError('An expected post has no confirmed publication URL')
    return True

def fetch_posts(base, token, batch, now):
    # Includes overdue work for seven days and everything before tomorrow's wake.
    query = urllib.parse.urlencode({
        'startDate': (now - dt.timedelta(days=7)).isoformat(),
        'endDate': batch['nextWake'],
    })
    request = urllib.request.Request(base.rstrip('/') + '/posts?' + query,
                                     headers={'Authorization': token})
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)

def run(args):
    folder = pathlib.Path(args.state_dir)
    if (folder / 'MAINTENANCE').exists():
        raise ValueError('Maintenance lock exists')
    config = json.loads((folder / 'config.json').read_text())
    base = config['baseUrl']
    if base != 'http://127.0.0.1:4007/api/public/v1':
        raise ValueError('Guard must query the local Postiz instance')
    token = (folder / 'api-key').read_text().strip()
    if not token:
        raise ValueError('Missing API key')
    batch_path = folder / 'batch.json'
    original = batch_path.read_bytes()
    batch = json.loads(original)
    fingerprints = []
    for index in range(2):
        now = dt.datetime.now(UTC)
        payload = fetch_posts(base, token, batch, now)
        validate(batch, payload, now)
        fingerprints.append(hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest())
        if index == 0:
            time.sleep(60)
    if fingerprints[0] != fingerprints[1] or batch_path.read_bytes() != original:
        raise ValueError('Posts or batch changed during the quiet interval')
    if (folder / 'MAINTENANCE').exists():
        raise ValueError('Maintenance started during verification')
    if not args.poweroff:
        print('DRY RUN: batch passed; no services stopped and no shutdown requested.')
        return
    if not (folder / 'ENABLE_POWEROFF').is_file():
        raise ValueError('Poweroff has not been armed')
    if config['composeDir'] != '/opt/postiz':
        raise ValueError('Unexpected Compose directory')
    # A single writer policy is required: this is not a generic job-queue lock.
    command = ['docker', 'compose', '--project-directory', '/opt/postiz']
    subprocess.run(command + ['stop', '-t', '120', 'postiz'], check=True, timeout=180)
    subprocess.run(command + ['stop', '-t', '120'], check=True, timeout=300)
    print('Verified batch, stopped Compose cleanly; requesting host shutdown.', flush=True)
    subprocess.run(['systemctl', 'poweroff'], check=True, timeout=30)

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--state-dir', default='/etc/postiz-power')
    parser.add_argument('--poweroff', action='store_true')
    try:
        run(parser.parse_args())
    except Exception as exc:
        # Do not print HTTP response bodies, headers, tokens or post contents.
        print(f'STAY ON: {type(exc).__name__}; inspect local configuration and Postiz status.', file=sys.stderr)
        sys.exit(1)
