"""Qawlak helper for free DaVinci Resolve (P15-46). Start it hidden, once per login:

    install.cmd                                 (Windows: installs it and starts it hidden at every sign-in; install.ps1)
    python3 qawlak_resolve_helper.py &          (macOS)
    python  qawlak_resolve_helper.py --once     (one scan, then exit; for a check)

Why it exists: a free-Resolve Workspace > Scripts menu script has no ffi, io, os.execute or require, and bmd's file calls
stop it (measured live in 21.1, 15 Sep 2026), so it can never read resolve.json or reach the network. It can render and
import. So "Qawlak Round-trip" renders the timeline audio into the queue folder and polls ImportMedia for <name>.srt;
this helper, outside Resolve, does the rest:

    queue = %APPDATA%\\Qawlak\\resolve-queue   (~/Library/Application Support/Qawlak/resolve-queue on macOS)
    <name>.mp4|.wav|.mov, once it stops growing  ->  ping  ->  resumable upload  ->  transcribe  ->  <name>.srt
    <name>.<ext>.done   claimed at pickup, so a restart or a second helper never uploads (and charges) the same render twice
    <name>.error.srt    a refusal: the menu script stops waiting; the reason is in the notification and the log
    <name> = <timeline>-<time>[-in<ms>ms][-x<choice>]   K31: -in: only the In/Out range was rendered, so the cues are shifted
                        by <ms> onto the timeline's clock; -x: the menu entry's language/dialect (CHOICES)
    cancel-<time>.edl   K31, "Qawlak Cancel": every render named at or before <time> is cancelled (its job refunded) and
                        answered with <name>.error.srt
    import/<digits>/    each Qawlak export ZIP in Downloads, unpacked into its own folder for "Qawlak Import"; the newest also
                        under fixed names in import/ (hard links) for a Resolve whose Media Storage listing gives nothing

Key, server and language come from resolve.json exactly as resolve_import.plugin_config reads them. The key goes only in
the Authorization header (resolve_import.Api) and is never logged. Messages go to a Windows notification in the key
file's language, else a message box, and always to %APPDATA%\\Qawlak\\resolve-helper.log. Stdlib only.
"""
import base64
import ctypes
import json
import os
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import zipfile
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))   # the repo layout; installed, it sits next to this file
import resolve_import as ri  # noqa: E402  (Api, plugin_config and the P15-43/45 message words)

RENDER_EXTS = ('.mp4', '.wav', '.mov')
POLL, STALE_AFTER, RETRY_AFTER = 2, 30 * 60, 30   # seconds; STALE_AFTER matches the menu script's wait
UNPARSED_AFTER = 60   # a render unchanged this long is taken even if its boxes never add up (a layout not seen yet)
NO_WINDOW = 0x08000000                              # CREATE_NO_WINDOW: a child of the hidden helper must not flash a console
EXPORT_FILES = ('captioned.mp4', 'source.mp4', 'clip.mp4', 'captions-both.srt')
KEEP_EXPORTS = 10   # ponytail: per-export folders kept; an older imported timeline's media goes offline after ten newer exports
# K31: the menu entries "Qawlak Round-trip (<Name>)" -> transcribe language and server dialect preset (resumable.DIALECTS).
CHOICES = {'arabic': ('ar', 'auto'), 'gulf': ('ar', 'gulf'), 'egyptian': ('ar', 'egyptian'), 'levantine': ('ar', 'levantine'),
           'maghrebi': ('ar', 'maghrebi'), 'msa': ('ar', 'msa'), 'english': ('en', 'english'), 'hindi': ('hi', 'hindi'), 'urdu': ('ur', 'urdu')}   # E37: english is a preset, so the upload's own job decodes English
NAME = re.compile(r'-(\d+)(?:-in(\d+)ms)?(?:-x([a-z]+))?$')
TIMESTAMP = re.compile(r'(\d\d):(\d\d):(\d\d),(\d\d\d)')
APP_ID = 'Qawlak.Resolve'   # registered by install.ps1 under HKCU\Software\Classes\AppUserModelId, so a toast says "Qawlak"
POWERSHELL_APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'


def render_settings(render):
    """(started os.time, In offset in ms, (language, dialect) or None) from a render's name; (None, 0, None) for other names."""
    m = NAME.search(render.stem)
    if not m: return None, 0, None
    return int(m.group(1)), int(m.group(2) or 0), CHOICES.get(m.group(3) or '')


def shift_srt(data, ms):
    """Every SRT timestamp moved later by ms (the In mark's offset), so the cues sit on the timeline's own clock."""
    if not ms: return data
    def moved(m):
        t = ((int(m.group(1)) * 60 + int(m.group(2))) * 60 + int(m.group(3))) * 1000 + int(m.group(4)) + ms
        return '%02d:%02d:%02d,%03d' % (t // 3600000, t // 60000 % 60, t // 1000 % 60, t % 1000)
    return TIMESTAMP.sub(moved, data.decode('utf-8')).encode('utf-8')


class Cancelled(Exception):
    """The editor ran Qawlak Cancel for this render."""


def cancel_requested(queue, render):
    """True when a cancel-<time>.* request in the queue names a time at or after this render's own start time."""
    started = render_settings(render)[0]
    if started is None: return False
    for request in queue.glob('cancel-*'):
        m = re.fullmatch(r'cancel-(\d+)(\.\w+)?', request.name)
        if m and int(m.group(1)) >= started: return True
    return False


def cancel_project_jobs(api, pid):
    """Cancel (and so refund) the project's queued or running jobs. A job that already finished answers 409: nothing to do."""
    for job in api.call('GET', '/api/jobs'):
        if job.get('project_id') == pid and job.get('status') in ('queued', 'running'):
            try: api.call('POST', f"/api/jobs/{job['id']}/cancel")
            except ri.QawlakError as err:
                if err.status != 409: raise


def cancelled_text(language):
    if language == 'ar': return 'قولك: أُلغيت الترجمة بطلب منك، ولم يُخصم وقت المعالجة لها.'
    return 'Qawlak: captioning cancelled as you asked; its minutes were not charged.'


def toast_app_id():
    """Qawlak's own app id once install.ps1 registered it, else PowerShell's (the toast then names "Windows PowerShell")."""
    try:
        import winreg
        winreg.CloseKey(winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Software\\Classes\\AppUserModelId\\' + APP_ID))
        return APP_ID
    except (ImportError, OSError):
        return POWERSHELL_APP_ID


def base_dir(env=None):
    env = os.environ if env is None else env
    if env.get('APPDATA'): return Path(env['APPDATA']) / 'Qawlak'
    if sys.platform == 'darwin': return Path.home() / 'Library' / 'Application Support' / 'Qawlak'
    return Path.home() / '.config' / 'qawlak'


def log(line):
    try:
        with (base_dir() / 'resolve-helper.log').open('a', encoding='utf-8') as f: f.write(time.strftime('%Y-%m-%d %H:%M:%S ') + line + '\n')
    except OSError: pass


def _messagebox(text):
    if os.name == 'nt': threading.Thread(target=ctypes.windll.user32.MessageBoxW, args=(None, text, 'Qawlak', 0x40040), daemon=True).start()


def notify(text):
    """A toast through Windows PowerShell's WinRT bridge (no new dependency); a message box when that fails."""
    log(text)
    try:
        if os.name == 'nt':
            # The text (server words included) travels as base64 and enters the toast as an XML text node: nothing in it
            # can end a PowerShell string or add XML (cso, 15 Sep 2026).
            b64 = base64.b64encode(text.encode('utf-8')).decode()
            app_id = toast_app_id()   # one of two fixed constants, never text from the server
            ps = ("$ErrorActionPreference='Stop';[Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime]>$null;"
                  "[Windows.Data.Xml.Dom.XmlDocument,Windows.Data.Xml.Dom,ContentType=WindowsRuntime]>$null;"
                  "$t=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('" + b64 + "'));"
                  "$x=New-Object Windows.Data.Xml.Dom.XmlDocument;"
                  "$x.LoadXml('<toast><visual><binding template=\"ToastGeneric\"><text>Qawlak</text><text/></binding></visual></toast>');"
                  "$x.GetElementsByTagName('text').Item(1).AppendChild($x.CreateTextNode($t))>$null;"
                  "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('" + app_id + "').Show([Windows.UI.Notifications.ToastNotification]::new($x))")
            r = subprocess.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', base64.b64encode(ps.encode('utf-16-le')).decode()],
                               capture_output=True, timeout=30, creationflags=NO_WINDOW)
        else:
            r = subprocess.run(['osascript', '-e', 'on run argv', '-e', 'display notification (item 1 of argv) with title "Qawlak"', '-e', 'end run', text],
                               capture_output=True, timeout=30)
        if r.returncode == 0: return
    except (OSError, subprocess.SubprocessError): pass
    _messagebox(text)


def key_refused(language):
    if language == 'ar': return 'قولك: لم يُقبل مفتاح الإضافة في resolve.json (أُلغي أو كُتب خطأ). أنشئ مفتاحاً جديداً من صفحة الإضافات في قولك.'
    return 'Qawlak: the plugin key in resolve.json was not accepted (revoked or mistyped). Create a new one on the Qawlak Plugins page.'


def failure_text(err, cfg):
    """The notification for a refused step, in the key file's language."""
    if isinstance(err, ri.QawlakError):
        if err.status == 401: return key_refused(cfg['language'])
        if err.status == 402: return ri.credit_wall_message(err.body, cfg['url'], cfg['language'])
        return ri.refusal_message(err.body, cfg['language']) or str(err)
    return str(err) or type(err).__name__


def transient(err):
    return isinstance(err, (urllib.error.URLError, OSError)) or (isinstance(err, ri.QawlakError) and (err.status or 0) >= 500)


def write_atomic(path, data):
    part = path.with_name(path.name + '.part')   # never .srt: the menu script's ImportMedia must not see a half file
    part.write_bytes(data)
    os.replace(part, path)


def looks_complete(path):
    """Writers fill in container sizes last: an MP4/MOV whose top-level boxes add up to the file size and hold moov and
    mdat, or a WAV whose RIFF size matches. A render paused mid-write fails this, so it is not uploaded (and charged) half-done."""
    size = path.stat().st_size
    with path.open('rb') as f:
        if path.suffix.lower() == '.wav':
            head = f.read(12)
            return len(head) == 12 and head[:4] == b'RIFF' and int.from_bytes(head[4:8], 'little') + 8 == size
        pos, boxes = 0, set()
        while pos < size:
            f.seek(pos); head = f.read(16)
            if len(head) < 8: return False
            n = int.from_bytes(head[:4], 'big')
            if n == 1 and len(head) == 16: n = int.from_bytes(head[8:16], 'big')
            if n < 8: return False   # 0 means "to the end of the file", what a writer leaves until it finishes
            boxes.add(head[4:8]); pos += n
        return pos == size and {b'moov', b'mdat'} <= boxes


def wait_ready(api, pid, render, timeout=3600):
    """resolve_import.Api.job's poll, with Qawlak Cancel honoured between polls."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if cancel_requested(render.parent, render):
            cancel_project_jobs(api, pid)
            raise Cancelled(pid)
        p = api.call('GET', f'/api/projects/{pid}')
        if p['status'] == 'error': raise SystemExit(p.get('error') or 'Qawlak job failed')
        if p['status'] == 'ready': return p
        time.sleep(POLL)
    raise SystemExit('Qawlak is still processing; open the project in the studio later.')


def process(render, cfg, api, queued=None):
    """One finished render -> <name>.srt next to it. Raises on failure (the caller sorts transient from final).
    queued learns the project id once transcription is queued, so a retry after a dropped connection polls that project
    instead of uploading and paying for the same render again."""
    queued = {} if queued is None else queued
    _, offset_ms, choice = render_settings(render)
    if render not in queued:
        ping = api.call('GET', '/api/plugin/ping')   # P15-45: a 403 here (an API key below Studio) stops before any upload
        low = ri.low_balance_message(ping, cfg['url'], cfg['language'])
        if low: notify(low)
        language, dialect = choice or (cfg['language'], cfg.get('dialect') or ri.dialect_for(cfg['language'], None))
        pid = api.upload(render, dialect)['id']
        body = {'language': language, 'dialect': dialect}
        try: api.call('POST', f'/api/projects/{pid}/transcribe', body)
        except ri.QawlakError as err:
            # A server before K31 answered 409 while the upload's own Arabic job ran (live R29, English). Wait for it once and ask
            # again; since K31 the server cancels and refunds that job instead, so this is only for an older server.
            if err.status != 409: raise
            wait_ready(api, pid, render)
            api.call('POST', f'/api/projects/{pid}/transcribe', body)
        queued[render] = pid
    p = wait_ready(api, queued[render], render)
    write_atomic(render.with_suffix('.srt'), shift_srt(api.call('GET', f"/api/projects/{p['id']}/export/srt"), offset_ms))
    queued.pop(render)
    log(f"{render.name}: {len(p.get('segments') or [])} cues (project {p['id']})")
    return p['id']


def scan(queue, state, cfg=None, api=None, now=None):
    """One pass over the queue. state carries sizes seen and retry times between passes; returns the renders finished."""
    now = time.time() if now is None else now
    done = []
    for render in sorted(queue.glob('*')):
        if render.suffix.lower() not in RENDER_EXTS or not render.is_file(): continue
        # L30 live 19 Sep: a fresh project's first round trip leaves the Deliver Location on this folder (no getter to put back an
        # empty one), so the editor's own next render lands here too; only the round trip's <name>-<os.time> renders are ours
        if not NAME.search(render.stem): continue
        marker = render.with_name(render.name + '.done')
        if marker.exists() or state['retry'].get(render, 0) > now: continue
        st = render.stat()
        seen, state['seen'][render] = state['seen'].get(render), (st.st_size, st.st_mtime)
        if seen != (st.st_size, st.st_mtime) or st.st_size == 0 or now - st.st_mtime < POLL:
            continue   # first sight, still growing, or touched a moment ago: wait for the next pass
        try: finished = looks_complete(render) or now - st.st_mtime > UNPARSED_AFTER
        except OSError: finished = False   # still open for writing
        if not finished: continue   # stalled mid-write (the menu script cannot write a flag file, so the container says)
        if cfg is None:
            try: cfg = ri.plugin_config()
            except SystemExit as err: return tell_once(state, 'config', str(err), now, render) or done
        if not cfg['key']:
            tell_once(state, 'config', f"Qawlak: no plugin key yet. Put {{\"key\": \"qwlk_...\"}} in {cfg['path']}.", now, render)
            continue
        try: fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY)   # the claim: replay- and double-helper-safe
        except FileExistsError: continue
        os.close(fd)
        if now - st.st_mtime > STALE_AFTER:   # the menu script gave up long ago; uploading now would only spend minutes
            marker.write_text('expired'); log(f'{render.name}: older than {STALE_AFTER // 60} min, skipped'); continue
        try:
            if cancel_requested(queue, render): raise Cancelled(None)   # cancelled before it was picked up: nothing uploaded
            marker.write_text('done ' + process(render, cfg, api or ri.Api(cfg['url'], cfg['key']), state.setdefault('queued', {})))
            done.append(render)
        except Cancelled:
            state.setdefault('queued', {}).pop(render, None)
            text = cancelled_text(cfg['language'])
            marker.write_text('cancelled'); log(f'{render.name}: cancelled'); notify(text)
            write_atomic(render.with_name(render.stem + '.error.srt'), f'1\n00:00:00,000 --> 00:00:05,000\n{text}\n'.encode())
        except (Exception, SystemExit) as err:  # SystemExit: resolve_import's refusals and job errors
            if transient(err):
                marker.unlink()
                log(f'{render.name}: will retry: {type(err).__name__} {getattr(err, "status", "")}')
                tell_once(state, render, offline_text(cfg), now, render)
                continue
            state['queued'].pop(render, None)
            text = failure_text(err, cfg)
            marker.write_text('failed'); notify(text)
            write_atomic(render.with_name(render.stem + '.error.srt'), f'1\n00:00:00,000 --> 00:00:05,000\n{text}\n'.encode())
        try: render.unlink()   # the timeline audio has done its job; don't keep it in the profile
        except OSError: pass
    for request in queue.glob('cancel-*'):   # a request only concerns renders already named when it was made
        try:
            if now - request.stat().st_mtime > STALE_AFTER: request.unlink()
        except OSError: pass
    return done


def tell_once(state, topic, text, now, render):
    state['retry'][render] = now + RETRY_AFTER
    if topic not in state['told']: state['told'].add(topic); notify(text)


def offline_text(cfg):
    if cfg['language'] == 'ar': return f"قولك: تعذّر الوصول إلى {cfg['url']}. سأعيد المحاولة كل {RETRY_AFTER} ثانية."
    return f"Qawlak: cannot reach {cfg['url']}. Retrying every {RETRY_AFTER} s."


def unpack_newest_export(downloads, queue, state):
    """The newest ZIP in Downloads that holds captions-both.srt, into its own folder queue/import/<ms of its time>/ for
    "Qawlak Import" (which finds the biggest number through Resolve's Media Storage, K31), so a newer export never replaces
    the files an earlier imported timeline uses. The same files are hard-linked under fixed names in queue/import/ for a
    Resolve whose listing gives nothing; a fixed name Resolve still holds open cannot be replaced (logged, retried)."""
    zips = sorted(downloads.glob('*.zip'), key=lambda z: z.stat().st_mtime, reverse=True) if downloads.is_dir() else []
    for z in zips:
        stamp = f'{z.name} {z.stat().st_mtime}'
        if state.get('export') == stamp: return None
        held, until = state.get('held', (None, 0))
        if held == stamp and time.time() < until: return None   # Resolve holds the last import open: try again later
        if stamp in state.setdefault('not_export', set()): continue
        try:
            with zipfile.ZipFile(z) as zf:
                names = set(zf.namelist())
                if 'captions-both.srt' not in names: state['not_export'].add(stamp); continue
                target = queue / 'import'
                own = target / str(int(z.stat().st_mtime * 1000))
                if not own.is_dir():   # unpacked whole into a .part folder first: Import never lists half an export
                    part = own.with_name(own.name + '.part')
                    shutil.rmtree(part, ignore_errors=True); part.mkdir(parents=True)
                    for name in EXPORT_FILES:   # only these root names: nothing in the ZIP chooses a path
                        if name in names:   # streamed: an export video can be larger than memory
                            with zf.open(name) as src, (part / name).open('wb') as dst: shutil.copyfileobj(src, dst)
                    os.replace(part, own)
                for name in EXPORT_FILES:   # every old fixed name first: a held one stops here, before a new video meets old captions
                    (target / name).unlink(missing_ok=True)
                for name in EXPORT_FILES:
                    if (own / name).is_file():
                        try: os.link(own / name, target / name)
                        except OSError: shutil.copyfile(own / name, target / name)
                exports = sorted((d for d in target.iterdir() if d.is_dir() and d.name.isdigit()), key=lambda d: int(d.name))
                for old in exports[:-KEEP_EXPORTS]: shutil.rmtree(old, ignore_errors=True)
        except zipfile.BadZipFile as err:
            log(f'import: {z.name}: {type(err).__name__}: {err}'); state['export'] = stamp; return None
        except OSError as err:   # a file Resolve still holds open: retried, so closing that project is enough
            if held != stamp: log(f'import: {z.name}: {type(err).__name__}: {err} (retrying every {RETRY_AFTER} s)')
            state['held'] = (stamp, time.time() + RETRY_AFTER); return None
        state['export'] = stamp; log(f'import: unpacked {z.name}')
        return target
    return None


def main(argv):
    queue = base_dir() / 'resolve-queue'
    queue.mkdir(parents=True, exist_ok=True)
    downloads = Path(os.environ.get('USERPROFILE') or Path.home()) / 'Downloads'
    state = {'seen': {}, 'retry': {}, 'told': set()}
    log(f'started, watching {queue}')
    while True:
        try:
            scan(queue, state)
            unpack_newest_export(downloads, queue, state)
        except (Exception, SystemExit) as err:  # keep watching; never let one bad file end the helper
            log(f'error: {type(err).__name__}: {err}')
        if '--once' in argv: return
        time.sleep(POLL)


if __name__ == '__main__':
    main(sys.argv[1:])
