#!/usr/bin/env python3
"""Publish a directory using Python's standard library. Credentials stay in a 0600 state file."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

TYPES = {'.html':'text/html','.htm':'text/html','.css':'text/css','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.woff':'font/woff','.woff2':'font/woff2','.json':'application/json','.txt':'text/plain'}

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('folder', type=Path, nargs='?')
    parser.add_argument('--site', required=True)
    parser.add_argument('--state', required=True, type=Path)
    parser.add_argument('--wait', action='store_true')
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument('--status', action='store_true', help='Fetch current owner feedback without uploading or creating an issue.')
    mode.add_argument('--review-link', action='store_true', help='Create a fresh private review link for the user, without uploading.')
    parser.add_argument('--timeout', type=int, default=1200)
    args = parser.parse_args()
    site = args.site.rstrip('/')
    url = urllib.parse.urlparse(site)
    if url.scheme != 'https' and not (url.scheme == 'http' and url.hostname in ('127.0.0.1','localhost')):
        raise ValueError('Use HTTPS, or a loopback URL for local development.')
    if url.username or url.password or url.query or url.fragment or url.path not in ('','/'):
        raise ValueError('Site must be a bare origin without credentials.')
    state_path = args.state.resolve()

    def request(path, method='GET', body=None, token=None, content_type='application/json'):
        payload = json.dumps(body).encode() if isinstance(body,dict) else body
        # Identify the client: Cloudflare can reject urllib's generic default
        # user agent on workers.dev before the request reaches our Worker.
        headers = {'Content-Type':content_type, 'User-Agent':'MyMagazine-Publisher/1.0', 'Accept':'application/json'}
        if token: headers['Authorization'] = 'Bearer ' + token
        req = urllib.request.Request(site+path, data=payload, headers=headers, method=method)
        # API calls must not redirect a bearer credential to another host.
        class NoRedirect(urllib.request.HTTPRedirectHandler):
            def redirect_request(self, req, fp, code, msg, headers, newurl): return None
        try:
            with urllib.request.build_opener(NoRedirect).open(req, timeout=60) as response:
                return json.load(response)
        except urllib.error.HTTPError as error:
            try:
                detail = json.load(error).get('error',{})
                raise RuntimeError(f"HTTP {error.code}: {detail.get('code')}: {detail.get('message')} " + json.dumps(detail.get('diagnostics',detail.get('issues',[])))) from None
            except (ValueError, AttributeError):
                raise RuntimeError(f'HTTP {error.code}; private state preserved.') from None

    def feedback(result):
        return {k:result.get(k) for k in ('id','revisionId','editionId','revisionNumber','latestRevisionNumber','pendingRevision','state','printReadiness','report','pageCount','approvedAt','proofUrl','pages')}

    def save_state(value):
        state_path.parent.mkdir(parents=True,exist_ok=True)
        temp = state_path.with_suffix('.pending')
        fd = os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
        with os.fdopen(fd,'w') as out: json.dump(value,out,indent=2)
        os.chmod(temp,0o600)
        os.replace(temp,state_path)

    def review_link(state):
        result = request('/v1/issues/'+state['id']+'/review-links','POST',{},state['ownerToken'])
        state.update(reviewUrl=result['reviewUrl'],reviewLinkExpiresAt=result['expiresAt'])
        save_state(state)
        print('Private review link:',result['reviewUrl'])
        print('Give this link only to the magazine owner. Open within 24 hours; it works once. The browser then remembers review access.')

    if args.status or args.review_link:
        state = json.loads(state_path.read_text())
        if state.get('site') != site:
            raise ValueError('The state file belongs to a different site.')
        if args.review_link:
            review_link(state)
            return 0
        deadline = time.monotonic()+args.timeout
        while True:
            result = request('/v1/issues/'+state['id'],token=state['ownerToken'])
            if not args.wait or result['state'] not in ('queued','reviewing') or time.monotonic()>=deadline:
                print(json.dumps(feedback(result),indent=2))
                return 0 if (result.get('printReadiness') or {}).get('status')=='ready' else 2
            time.sleep(5)
    if args.folder is None:
        parser.error('folder is required unless --status or --review-link is used')
    folder = args.folder.resolve()
    if state_path == folder or folder in state_path.parents:
        raise ValueError('Keep the private state file outside the magazine folder.')
    capabilities = request('/v1/capabilities')
    manifest = json.loads((folder/'magazine.json').read_text())
    files = []
    for file in sorted(folder.rglob('*')):
        if file.is_symlink(): raise ValueError('Do not package symlinks.')
        if not file.is_file() or file == folder/'magazine.json': continue
        path = file.relative_to(folder).as_posix()
        if not re.fullmatch(r'[a-zA-Z0-9][a-zA-Z0-9_./-]*',path) or any(p in ('.','..','') for p in path.split('/')):
            raise ValueError(f'Unsupported path: {path}')
        if file.suffix.lower() not in TYPES: raise ValueError(f'Unsupported file: {path}')
        data = file.read_bytes()
        files.append({'path':path,'size':len(data),'sha256':hashlib.sha256(data).hexdigest(),'type':TYPES[file.suffix.lower()]})
    limits = capabilities['limits']
    if len(files)>limits['files'] or sum(f['size'] for f in files)>limits['totalBytes'] or any(f['size']>limits['fileBytes'] for f in files):
        raise ValueError('The package exceeds server limits.')
    digest = hashlib.sha256(json.dumps({'manifest':manifest,'files':files},sort_keys=True).encode()).hexdigest()
    state = json.loads(state_path.read_text()) if state_path.exists() else {}
    if state and state.get('site') != site: raise ValueError('The state file belongs to a different site.')

    def save():
        save_state(state)

    if not state:
        result = request('/v1/issues','POST',{'title':manifest['title'],'description':manifest.get('description','')})
        state = {**result,'site':site}
        save()
    owner = state['ownerToken']
    current = request('/v1/issues/'+state['id'],token=owner)
    if state.get('digest') != digest or not state.get('uploadId'):
        upload = request('/v1/issues/'+state['id']+'/uploads','POST',{'manifest':manifest,'files':files,'parentRevisionId':current['revisionId']},owner)
        state.update(uploadId=upload['uploadId'],digest=digest,finalized=False)
        save()
    if not state.get('finalized'):
        # Finalize may have succeeded before a connection was interrupted.
        current = request('/v1/issues/'+state['id'],token=owner)
        if current['revisionId'] == state['uploadId'] and current['state'] != 'awaiting_upload':
            state['finalized'] = True
        else:
            for file in files:
                data = (folder/file['path']).read_bytes()
                if hashlib.sha256(data).hexdigest() != file['sha256']: raise ValueError('Source changed during upload; run again.')
                request('/v1/uploads/'+state['uploadId']+'/files/'+file['path'],'PUT',data,owner,file['type'])
            request('/v1/uploads/'+state['uploadId']+'/finalize','POST',token=owner)
            state['finalized'] = True
        save()
    review_link(state)
    print('Private editing credentials saved to:',state_path)
    if args.wait:
        deadline = time.monotonic()+args.timeout
        while time.monotonic()<deadline:
            result = request('/v1/issues/'+state['id'],token=owner)
            if result['state'] not in ('queued','reviewing'):
                print(json.dumps(feedback(result),indent=2))
                return 0 if (result.get('printReadiness') or {}).get('status')=='ready' else 2
            time.sleep(5)
        print('Review is still running. The private review page shows progress; the saved publishing state is preserved.')
        return 2
    return 0

if __name__ == '__main__':
    try: sys.exit(main())
    except (ValueError,RuntimeError,OSError,KeyError) as error:
        print(str(error),file=sys.stderr)
        sys.exit(1)
