#!/usr/bin/env python3
"""
Puts BidBrain's timed jobs on this Mac's own scheduler (launchd), the same
way every existing com.bidbrain.* job was set up by hand, so a dealer can
add the daily reads without a session on the Mac writing plists for them
(Steven 2026-09-07: "set didn't win sync and under offer check to run
daily like purchases sync, i think compmatch should be the same too",
then "mark will want it to work the same way too").

  python3 setup_schedule.py                 show every BidBrain job this Mac has
  python3 setup_schedule.py --install       add the three daily reads at the default times
  python3 setup_schedule.py --install lost_bids=16:05 compmatch=16:45
                                            only those jobs, at those times
  python3 setup_schedule.py --remove lost_bids
                                            take one out again

What it can install (key, what runs, default time):

  lost_bids     lost_bids_run.py             16:05   Didn't win sync
  under_offer   daily_run.py --under-offer   16:30   Under offer check
  compmatch     share_comps.py --sync        16:45   CompMatch sync

The defaults sit clear of the jobs a Mac normally already runs (Purchases
sync 15:50, the DealerKit push 16:15, the 17:00 logins, the 17:10 run):
lost_bids and under_offer read Motorway through the one shared browser
profile, which only one process may hold at a time (bidbrain.browser
.open_context), so nothing that touches Motorway may fire in the same
minutes. compmatch uses no browser at all, only git.

A job this Mac already has (by label) is LEFT ALONE unless its time is
given on the command line, so a Mac that set one of these up by hand
earlier (Mark's com.bidbrain.lostbids at 15:55) keeps its own time on a
plain --install. Every file follows the conventions of the jobs already
here: the absolute path of this very python (launchd does not use the
shell PATH), WorkingDirectory at the project root, one log per job under
data/logs, no KeepAlive or RunAtLoad (a timed job fires at its time and
exits). Loaded with launchctl straight away, so nothing waits for a
restart. Never touches any other com.bidbrain.* file.

Opt in only. Nothing in BidBrain calls this on its own, so a Mac that
never runs it is exactly as it was.
"""

import os
import plistlib
import re
import subprocess
import sys

PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
AGENTS_DIR = os.path.expanduser("~/Library/LaunchAgents")

# key: (label, argv after python, default HH:MM, log name, plain words)
JOBS = {
    "lost_bids": ("com.bidbrain.lostbids", ["lost_bids_run.py"], "16:05", "lostbids", "Didn't win sync"),
    "under_offer": ("com.bidbrain.under-offer", ["daily_run.py", "--under-offer"], "16:30", "under-offer", "Under offer check"),
    "compmatch": ("com.bidbrain.compmatch", ["share_comps.py", "--sync"], "16:45", "compmatch", "CompMatch sync"),
}


def parse_time(text):
    """'16:05' -> (16, 5). Anything else is refused with a clear message."""
    m = re.fullmatch(r"(\d{1,2}):(\d{2})", (text or "").strip())
    if not m or not (0 <= int(m.group(1)) <= 23 and 0 <= int(m.group(2)) <= 59):
        raise ValueError(f"'{text}' is not a time like 16:05")
    return int(m.group(1)), int(m.group(2))


def parse_requests(words):
    """['lost_bids=16:05', 'compmatch'] -> {'lost_bids': (16, 5), 'compmatch': None}.
    No words at all means every job at its default. None means 'keep the
    time the Mac already has, else the default'."""
    if not words:
        return {k: None for k in JOBS}
    out = {}
    for w in words:
        key, _, t = w.partition("=")
        if key not in JOBS:
            raise ValueError(f"'{key}' is not a job this can install ({', '.join(JOBS)})")
        out[key] = parse_time(t) if t else None
    return out


def plist_for(key, hour, minute, python=None, root=None):
    """The launchd file for one job, matching the hand written ones."""
    label, argv, _, log, _ = JOBS[key]
    root = root or PROJECT_ROOT
    return {
        "Label": label,
        "ProgramArguments": [python or sys.executable] + [os.path.join(root, argv[0])] + argv[1:],
        "WorkingDirectory": root,
        "StartCalendarInterval": {"Hour": hour, "Minute": minute},
        "StandardOutPath": os.path.join(root, "data", "logs", f"{log}.log"),
        "StandardErrorPath": os.path.join(root, "data", "logs", f"{log}.log"),
    }


def plist_path(key, agents_dir=None):
    return os.path.join(agents_dir or AGENTS_DIR, JOBS[key][0] + ".plist")


def existing_time(path):
    """(hour, minute) of a job already on this Mac, or None."""
    try:
        with open(path, "rb") as fh:
            cal = plistlib.load(fh).get("StartCalendarInterval") or {}
        return int(cal.get("Hour", 0)), int(cal.get("Minute", 0))
    except Exception:
        return None


def plan(requests, agents_dir=None):
    """What --install would do, as (key, hour, minute, action) rows, where
    action is 'add', 'change' or 'keep'. Pure, so it is testable without a
    Mac: the launchctl calls happen in install()."""
    rows = []
    for key, wanted in requests.items():
        path = plist_path(key, agents_dir)
        have = existing_time(path)
        if wanted is None:
            if have:
                rows.append((key, have[0], have[1], "keep"))
                continue
            wanted = parse_time(JOBS[key][2])
        rows.append((key, wanted[0], wanted[1], "change" if have and have != wanted else "keep" if have else "add"))
    return rows


def _launchctl(*args):
    r = subprocess.run(["launchctl", *args], capture_output=True, text=True)
    return r.returncode == 0, (r.stderr or r.stdout).strip()


def install(requests):
    os.makedirs(AGENTS_DIR, exist_ok=True)
    os.makedirs(os.path.join(PROJECT_ROOT, "data", "logs"), exist_ok=True)
    for key, hour, minute, action in plan(requests):
        path = plist_path(key)
        words = JOBS[key][4]
        if action == "keep":
            print(f"{words}: already runs at {hour:02d}:{minute:02d}, left as it is")
            continue
        if action == "change":
            _launchctl("unload", path)
        with open(path, "wb") as fh:
            plistlib.dump(plist_for(key, hour, minute), fh)
        ok, msg = _launchctl("load", "-w", path)
        if ok:
            print(f"{words}: {'now' if action == 'add' else 'moved to'} {hour:02d}:{minute:02d} every day ({os.path.basename(path)})")
        else:
            print(f"{words}: file written but launchctl could not load it: {msg}")
            return 1
    return 0


def remove(keys):
    for key in keys:
        path = plist_path(key)
        words = JOBS[key][4]
        if not os.path.exists(path):
            print(f"{words}: not scheduled on this Mac, nothing to remove")
            continue
        _launchctl("unload", "-w", path)
        os.remove(path)
        print(f"{words}: removed")
    return 0


def show():
    import glob
    files = sorted(glob.glob(os.path.join(AGENTS_DIR, "com.bidbrain.*.plist")))
    if not files:
        print("No BidBrain jobs are scheduled on this Mac.")
        return 0
    for f in files:
        t = existing_time(f)
        when = f"{t[0]:02d}:{t[1]:02d}" if t else "always on"
        print(f"{when}  {os.path.basename(f)[:-6]}")
    return 0


def _record(ok, summary):
    """The outcome for the Run menu's board and Dealer OS, when run as the
    install_schedule pass. Best effort: a missing record never fails the
    install itself."""
    try:
        sys.path.insert(0, PROJECT_ROOT)
        from bidbrain import runs
        runs.begin("install_schedule")
        runs.finish("install_schedule", ok, summary)
    except Exception:
        pass


def main(argv=None):
    argv = list(sys.argv[1:] if argv is None else argv)
    if sys.platform != "darwin" and argv:
        print("This sets up launchd jobs, which only exist on a Mac.")
        return 1
    try:
        if not argv:
            return show()
        if argv[0] == "--install":
            requests = parse_requests(argv[1:])
            rc = install(requests)
            words = ", ".join(f"{JOBS[k][4]} {h:02d}:{m:02d}" for k, h, m, _a in plan(requests))
            _record(rc == 0, f"Timed jobs set up: {words}." if rc == 0 else "launchctl could not load a job, see data/logs.")
            return rc
        if argv[0] == "--remove":
            keys = argv[1:]
            bad = [k for k in keys if k not in JOBS]
            if not keys or bad:
                raise ValueError(f"say which job to remove ({', '.join(JOBS)})")
            return remove(keys)
    except ValueError as e:
        print(e)
        return 1
    print(__doc__.strip())
    return 1


if __name__ == "__main__":
    sys.exit(main())
