"""
The one registry of everything BidBrain can run, and the one way a run
reports and finishes (Steven 2026-09-02: "check the way all of the runs are
set up... it all seems sporadic, sometimes runs work, sometimes they dont,
sometimes the icon shows things its not supposed to... the maintenance
checks dont always act like the auction house runs. it needs simplified and
work correctly, consistently and look the same across all screens").

Before this, the same 17 runs were described in five hand written maps in
serve.py (proc slots, stop keys, labels, sites, gates) that had drifted
apart, and finished through four separate helpers in four scripts. Now:

  RUNS         one entry per run: key (the db.run_status key, used
               everywhere, never renamed on the way), label, the argv
               that starts it, the connection it belongs to, the Settings
               toggle that gates it, whether it opens a browser window,
               and its scope (bulk, row, login).
  progress()   the shared progress file write (was daily_run._progress).
  finish()     the terminal write: progress AND the durable run record in
               one call (was daily_run._finish, purchases_run._finish_sync,
               lost_bids_run._finish, share_comps._record). A row scoped
               run writes progress only, by design: one car's problem
               lives on the purchases page's own bar, never the app wide
               light.
  guard()      wraps a script's entry point so it CANNOT end without a
               terminal write: a normal return, a RunSkipped (switched off
               in Settings, nothing to do), any exception, SystemExit and
               Ctrl-C all land in finish(). "skipped" is a real phase, grey,
               never red: off is not broken.

Pure data plus file and db writes, no browser, importable by every script
and by serve.py.
"""

import json
import os
import sys
import time
from dataclasses import dataclass, field

from bidbrain import db

HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROGRESS = os.path.join(HERE, "data", "run_progress.json")

# Phases a live run writes while it is genuinely working. "starting" is
# stamped by the server at spawn and deliberately NOT here (see serve's own
# rule 2), and the terminal phases are done, error, skipped, stopped.
ACTIVE_PHASES = ("stock", "reading", "valuing", "photos", "writing",
                 "glass-login", "clickdealer-login", "dealerkit-login")
TERMINAL_PHASES = ("done", "error", "skipped", "stopped")


@dataclass(frozen=True)
class Run:
    key: str
    label: str            # "The daily run", used in every sentence about it
    argv: tuple           # after the interpreter, relative to the project root
    busy: str             # "A daily run is in progress."
    starting: str         # the progress message primed at spawn
    site: str = None      # connection key, for the Integrations tab and hiding
    gate: str = None      # integration toggle key that switches it off
    headed: bool = False  # opens a browser window on the Mac (confirm first)
    scope: str = "bulk"   # bulk | row | login
    win: bool = False     # accepts BIDBRAIN_GLASS_WIN (x,y) for the window
    confirm: str = ""     # the question the page asks before starting it
    quiet: bool = False   # never touches the progress file: no bar, no light (the automatic stage check)


def _r(key, label, argv, busy, starting, **kw):
    return Run(key=key, label=label, argv=tuple(argv), busy=busy, starting=starting, **kw)


RUNS = {r.key: r for r in (
    _r("run", "The daily run", ["daily_run.py"],
       "A daily run is in progress.", "Starting a daily run...", headed=True),
    _r("glass", "Glass's checks", ["daily_run.py", "--glass"],
       "A Glass's pass is in progress.", "Starting a Glass's pass...",
       site="glass", gate="glass", headed=True, win=True,
       confirm="Run Glass's checks now? A Glass's login window opens on the Mac. Log in there, then it adds Glass's to these cars and re prices."),
    _r("clickdealer", "Clickdealer stock", ["daily_run.py", "--clickdealer"],
       "A Clickdealer stock pass is in progress.", "Starting a Clickdealer stock pass...",
       site="clickdealer", headed=True, win=True,
       confirm="Run the Clickdealer stock refresh now? A Clickdealer window opens on the Mac if needed."),
    _r("dealerkit_stock", "DealerKit stock", ["daily_run.py", "--dealerkit"],
       "A DealerKit stock pass is in progress.", "Starting a DealerKit stock pass...",
       site="dealerkit", gate="dealerkit_stock", headed=True, win=True,
       confirm="Run the DealerKit stock refresh now? A DealerKit login window opens on the Mac if needed, then it refreshes the in stock and gap flags. Keeps the existing valuations, no re valuing."),
    _r("dealerkit_purchases", "DealerKit purchases push", ["daily_run.py", "--dealerkit-purchases"],
       "A DealerKit purchases push is in progress.", "Starting a DealerKit purchases push...",
       site="dealerkit", gate="dealerkit_purchases", headed=True, win=True,
       confirm="Push new purchases into DealerKit now? A DealerKit login window opens on the Mac if needed, then every purchase not already there gets added as a Due In record with its photos, service history documents and purchase price. Retail price is left for you to set."),
    _r("dealerkit_check", "The DealerKit record check", ["daily_run.py", "--dealerkit-check"],
       "A DealerKit check is in progress.", "Starting a DealerKit check...",
       site="dealerkit", gate="dealerkit_purchases", headed=True, win=True,
       confirm="Check every purchase against its real DealerKit record now? This opens a DealerKit window and reads them all, it can take a few minutes."),
    _r("motorway_probe", "Motorway one off check", ["daily_run.py", "--motorway-probe"],
       "A Motorway check is in progress.", "Checking Motorway's export and a car page...",
       site="motorway", quiet=True),
    _r("dealerkit_survey", "DealerKit costs screen survey", ["purchases_run.py", "--survey-dealerkit"],
       "A DealerKit costs screen survey is in progress.", "Looking at DealerKit's costs screen...",
       site="dealerkit", gate="dealerkit_purchases", headed=True, win=True,
       confirm="Look at DealerKit's costs screen for one car and write every step down? A DealerKit window opens on the Mac. Nothing is saved on DealerKit."),
    _r("dealerkit_probe", "DealerKit API probe", ["daily_run.py", "--dealerkit-probe"],
       "A DealerKit API probe is in progress.", "Probing DealerKit's APIs...",
       site="dealerkit", quiet=True),
    _r("dealerkit_stages", "DealerKit stage check", ["daily_run.py", "--dealerkit-stages"],
       "A DealerKit stage check is in progress.", "Checking DealerKit's stages...",
       site="dealerkit", gate="dealerkit_purchases", quiet=True),
    _r("dealerkit_sale_prices", "DealerKit sale price read", ["daily_run.py", "--dealerkit-sale-prices"],
       "A DealerKit sale price read is in progress.", "Reading what a sold car went for...",
       site="dealerkit", gate="dealerkit_purchases", quiet=True),
    _r("sales_backfill", "Sales history backfill", ["daily_run.py", "--sales-backfill"],
       "A sales history backfill is in progress.", "Sending every past sale to Dealer OS...",
       site="dealerkit", quiet=True),
    _r("lecapital_funding", "LE Capital funding sync", ["daily_run.py", "--lecapital-funding"],
       "An LE Capital funding sync is in progress.", "Starting an LE Capital funding sync...",
       site="lecapital", gate="lecapital_funding", headed=True, win=True,
       confirm="Sync DealerKit funding with LE Capital now? A window opens on the Mac for LE Capital and, if needed, DealerKit logins, then it reads LE Capital's Current Stock and Stock History and adds or settles DealerKit funding records to match. A value disagreement on a car live on both sides is only ever reported, never changed automatically."),
    _r("purchases", "Purchases sync", ["purchases_run.py"],
       "A purchases sync is in progress.", "Starting a purchases sync..."),
    _r("lost_bids", "Didn't win sync", ["lost_bids_run.py"],
       "A Didn't win sync is in progress.", "Starting a Didn't win sync..."),
    _r("compmatch", "CompMatch sync", ["share_comps.py", "--sync"],
       "A CompMatch sync is in progress.", "Starting a CompMatch sync...",
       confirm="Sync CompMatch data with the other dealership on this repo? Pulls their latest comps, then commits and pushes your own (make/model, winning price, retail, mileage, age, transmission, engine size and grade only, never a registration, price breakdown or margin). Needs DEALER_NAME set in your own dealer_config.py."),
    _r("watchlist", "Watchlist sync", ["daily_run.py", "--watchlist-sync"],
       "A watchlist sync is in progress.", "Starting a watchlist sync...",
       confirm="Sync your starred cars to the auction houses' own watchlists now?"),
    _r("deep_read", "Shortlist deep read", ["daily_run.py", "--deep-read"],
       "A shortlist deep read is in progress.", "Reading the shortlisted cars' own pages...",
       quiet=True),
    _r("under_offer", "Under offer check", ["daily_run.py", "--under-offer"],
       "An under offer check is in progress.", "Starting an under offer check...",
       confirm="Check Motorway and Carwow for cars under offer now? Reads both platforms, takes a minute or two."),
    _r("dealer_os_push", "Dealer OS push", ["daily_run.py", "--dealer-os-push"],
       "A Dealer OS push is in progress.", "Starting a Dealer OS push...",
       site="dealer_os", gate="dealer_os_push",
       confirm="Send the current list (shortlisted and held cars, with prices) into Dealer OS now? This happens automatically after every run, so only use this to re send."),
    # The Didn't win and Under offer timed jobs, installed on this Mac on
    # request from Dealer OS (Steven 2026-09-08: "yes add the didnt win and
    # under offer jobs"), so nobody needs the Mac's own screen for it.
    # setup_schedule.py leaves a job already on the Mac at its own time.
    _r("install_schedule", "Timed jobs setup", ["setup_schedule.py", "--install", "lost_bids", "under_offer"],
       "The timed jobs are being set up.", "Setting up the Didn't win and Under offer timed jobs..."),
    _r("dealer_os_pull", "Dealer OS sync", ["daily_run.py", "--dealer-os-pull"],
       "A Dealer OS sync is in progress.", "Starting a Dealer OS sync...",
       site="dealer_os", gate="dealer_os_push",
       confirm="Collect stars, hides, views and run requests made in Dealer OS now? This happens on its own every few seconds while the cockpit server is up, so only use this to force it."),
    # One car at a time, from the purchases page. Their failures stay on that
    # page's own bar and never turn the app wide light red (scope row).
    _r("dealerkit_push_row", "DealerKit push", ["purchases_run.py", "--push-dealerkit"],
       "A DealerKit push for one car is in progress.", "Starting a DealerKit push...",
       site="dealerkit", gate="dealerkit_purchases", headed=True, scope="row"),
    _r("dealerkit_check_row", "DealerKit record check", ["purchases_run.py", "--check-dealerkit"],
       "A DealerKit check for one car is in progress.", "Starting a DealerKit check...",
       site="dealerkit", gate="dealerkit_purchases", headed=True, scope="row"),
    _r("retail_reprice", "Re price retail estimates", ["purchases_run.py", "--reprice-retail"],
       "A retail re pricing is in progress.", "Re pricing retail estimates...",
       confirm="Re price every live car's Retail est. with this dealership's own margin and price ending from Settings? A figure typed in by hand is left alone. A Due in car on DealerKit has the new figure sent there by the next automatic run."),
    _r("purchase_check", "Purchase re read", ["purchases_run.py", "--check-purchase"],
       "A purchase re read is in progress.", "Starting a purchase re read...", scope="row"),
    _r("login", "Login window", ["login.py"],
       "A login window is open.", "Opening a login window...", headed=True, scope="login"),
)}

# The platforms the daily run records under their own keys inside run().
PLATFORM_LABELS = {"motorway": "Motorway", "carwow": "Carwow", "auction4cars": "Auction4Cars",
                   "dealerauction": "Dealer Auction", "dealerway": "DealerWay"}


def label_for(key):
    """What a run_status key is called on screen, platforms included."""
    if key in RUNS:
        return RUNS[key].label
    return PLATFORM_LABELS.get(key, key)


def client_registry():
    """The part of the registry a page needs: label, whether to confirm and
    with what, and whether it is one car's own job."""
    return {k: {"label": r.label, "confirm": r.confirm, "headed": r.headed,
                "scope": r.scope, "starting": r.starting}
            for k, r in RUNS.items() if r.scope != "login"}


class RunSkipped(Exception):
    """Raised by a run that has nothing to do and should say so without
    being a failure: switched off in Settings, not configured on this Mac,
    no saved run to work on. Lands as phase "skipped", grey, never red."""


def progress(phase, message="", done=0, total=0, partial=False, key=None):
    """Write the shared progress file every page's bar and light read. Best
    effort: reporting must never break the run it reports on. `key` names
    the pass the line belongs to (begin() sets it for the whole pass), so
    Dealer OS can say "Purchases sync: reading..." rather than a bare line
    and can tell which pass just finished (Steven 2026-09-08: "it needs to
    be more clear what is happening at all times")."""
    try:
        with open(PROGRESS, "w", encoding="utf-8") as f:
            json.dump({"phase": phase, "message": message, "done": done,
                       "total": total, "partial": bool(partial), "ts": time.time(),
                       "key": key or _state.get("key")}, f)
    except Exception:
        pass


_state = {"finished": False, "key": None}


def begin(key):
    """Name the pass this process is, so every progress line it writes
    carries it."""
    _state["key"] = key


def finish(key, ok, message, done=0, total=0):
    """The end of one run, both halves at once: the terminal progress write
    the bar and light watch for, and the durable record the Run menu's
    board shows. ok=True is done, False is error, None is skipped. A row
    scoped run writes no record (its outcome belongs to its own page)."""
    phase = "done" if ok else ("skipped" if ok is None else "error")
    run = RUNS.get(key)
    _state["key"] = key
    if not (run is not None and run.quiet):
        progress(phase, message, done, total)
    _state["finished"] = True
    if run is not None and run.scope != "bulk":
        return
    try:
        db.record_run_status(key, ok is not False,
                             message if ok is not None else f"Skipped. {message}")
    except Exception:
        pass


def first_line(text, cap=160):
    lines = (str(text) or "").strip().splitlines()
    return (lines[0].strip() if lines else "")[:cap]


def guard(key, fn):
    """Run a script's entry point so it cannot end without a terminal write.
    A normal return that never called finish() records "Finished"; a
    RunSkipped records skipped; anything else records the failure and re
    raises so the exit code and the log still say so."""
    _state["finished"] = False
    try:
        fn()
    except RunSkipped as e:
        finish(key, None, first_line(e) or "Nothing to do.")
        return
    except SystemExit as e:
        if not _state["finished"]:
            code = e.code if isinstance(e.code, int) else 1
            finish(key, code == 0, "Finished" if code == 0 else f"Stopped early (exit {code})")
        raise
    except KeyboardInterrupt:
        if not _state["finished"]:
            finish(key, False, "Interrupted")
        raise
    except Exception as e:
        if not _state["finished"]:
            finish(key, False, first_line(e) or type(e).__name__)
        raise
    if not _state["finished"]:
        finish(key, True, "Finished")
