"""
Pushes a purchased car into DealerKit (Right Drive's DMS) as a real "Due In"
stock record, with its photos and service history documents attached.

Built against the real live app (rightdrive.app.dealerkit.uk), not guessed
markup: see the 2026-08-21 CLAUDE.md entry for the reconnaissance this was
built from. DealerKit is a Vue single page app whose form fields carry no
name/id/placeholder attributes worth relying on (custom ui-textbox/ui-select
components, several rendered as hidden duplicate instances at once), so every
click here finds the one currently VISIBLE match rather than trusting a fixed
selector index, the same defence used throughout this module.

Scope, deliberately narrow for this first build (2026-08-21, Mark: "lets do a
test to confirm everything works first then automatic"):
  - Creates the due in record from the registration alone (DealerKit's own
    DVLA lookup fills in make/model/spec).
  - Types BidBrain's own purchase price into the Purchase Details step's
    Purchase Price field before Save. DealerKit has its own live Motorway
    integration that can fill this in (and a full itemised Buyers Premium /
    Delivery Charge / Indemnity Fee breakdown, plus real documents) entirely
    on its own, proven live twice, but ONLY ever seen firing on a genuinely
    human driven session (Mark himself, mouse and keyboard, twice) and NEVER
    on an automated one (three separate attempts: two headless script runs
    and one slow, deliberate, click by click session driven by Claude
    through the Browser pane, the last one checked again 15+ minutes later
    and still blank). See the 2026-08-21 evening CLAUDE.md entries for the
    full pattern. So for an automated push, typing the figure in ourselves
    is the only way it does not sit blank forever, confirmed with Mark
    before re adding this (the same mechanism was built, proven to work,
    then removed earlier the same day on the mistaken theory that DealerKit's
    own integration made it redundant for every kind of session, not just a
    human one). Only the single combined Purchase Price is set (BidBrain's
    own already computed total), not an itemised Buyers Premium / Delivery
    Charge / Indemnity Fee breakdown, that needs a separate Add Expense flow
    not yet investigated. Nothing else on the Stocklisting step is touched
    (Bought From, invoice ref, VAT qualifier, finance settlement, funding,
    sales location, Retail Price): Retail Price is Mark's own market call,
    typed in by hand, and "Bought From" needs matching or creating a
    DealerKit contact, not attempted here.
  - Uploads up to 4 real exterior photos, and any service history document
    and V5C document photos already captured, via DealerKit's own Edit >
    Images / Edit > Documents upload widgets (a plain multi file input
    each, drag and drop or a file picker, unrelated to the two despite the
    shared UI chrome). Service history and V5 photos go up together as one
    Documents upload (2026-08-23, Mark: "add the v5 to the automation").
    Still never the seller's driving licence, which stays uncaptured
    everywhere in this project (see motorway.read_v5_photos), and still
    never captured at all for a listing that has not actually been bought,
    the V5 reader only ever runs against a genuine purchase's own summary
    page in purchases_run.py.

Never guesses: if a step's expected element is not found, this raises loudly
rather than clicking something else and leaving a half filled record.
"""

import datetime
import json
import os
import re
import tempfile
import time
from urllib.parse import urlparse

import dealer_config

from . import plates

# Every dealer's own DealerKit tenant, from dealer_config.py — the same
# per-dealer resolution bidbrain/browser.py's own SITES["dealerkit"]
# already uses (dealer_config.DEALERKIT_BASE_URL). This constant used to
# be hardcoded to Right Drive's own subdomain ("rightdrive.app.dealerkit
# .uk"), left over from when this module was first built on Mark's
# account, and never made per-dealer the way browser.py was. Found live
# 2026-09-01: on Steven's own account (really-easy-car-credit), every
# check and push in this module was silently calling MARK's tenant with
# Steven's own cookies, which carry no valid session there at all, so
# DealerKit served its sign in page and every call raised
# DealerkitSessionExpired — indistinguishable from a genuinely dead
# login, which is exactly why logging in again never fixed it (Steven:
# "everytime i try to check dealerkits records i get this, even though
# ive logged in").
DEALERKIT_BASE = dealer_config.DEALERKIT_BASE_URL


def _visible(locator, timeout=10.0):
    """The first match of a locator that is actually rendered on screen right
    now. DealerKit renders several hidden duplicate instances of the same
    modal/component at once, so `.first` or a fixed index is not reliable,
    only the real bounding box tells you which one a human would see."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            count = locator.count()
        except Exception:
            count = 0
        for i in range(count):
            b = locator.nth(i).bounding_box()
            if b and b["width"] > 0 and b["height"] > 0:
                return locator.nth(i), b
        time.sleep(0.25)
    return None, None


def _click_visible(page, locator, timeout=10.0):
    el, b = _visible(locator, timeout)
    if not b:
        return False
    page.mouse.click(b["x"] + b["width"] / 2, b["y"] + b["height"] / 2)
    return True


def _click_visible_text(page, text, exact=False, timeout=10.0, within=None):
    """within, if given, is a locator (or selector string) to scope the text
    search to, for example the currently open modal. Needed because
    _visible() only checks that an element has a real bounding box, not
    that it is actually inside the viewport: a same-worded heading lower
    down the page (found live, 2026-08-23: a "Documents" section further
    down a vehicle's own page, unrelated to the Edit Vehicle modal's own
    "Documents" tile) can still have width/height > 0 while sitting off
    screen, and DOM order alone decided which one won, sometimes the wrong
    one. Scoping to the open modal removes the ambiguity outright rather
    than trying to out-guess which "visible" match a human would pick."""
    root = page.locator(within) if isinstance(within, str) else (within or page)
    return _click_visible(page, root.get_by_text(text, exact=exact), timeout)


def _evaluate_until(page, js_fn, arg=None, timeout=10.0, poll=0.3):
    """Like page.evaluate, but retries until it returns a truthy value or
    timeout runs out. A fixed page.wait_for_timeout before a ONE SHOT
    evaluate (the pattern used throughout this module before this helper
    existed) was found live 2026-08-23 to be genuinely racy for
    set_expense_tax_date specifically: two back to back runs against the
    exact same vehicle, same code, same 1500ms wait, landed in two
    different real states (one correctly inside "Edit Expense #NNN", one
    still sitting on the outer "Choose an area to edit" menu), meaning
    DealerKit's own SPA transition time genuinely varies run to run, not
    something a single fixed wait can cover reliably."""
    deadline = time.time() + timeout
    while time.time() < deadline:
        result = page.evaluate(js_fn, arg) if arg is not None else page.evaluate(js_fn)
        if result:
            return result
        time.sleep(poll)
    return None


def _click_containing(page, needle, timeout=10.0):
    """Click the smallest visible element whose text contains needle
    (case/space insensitive). Used wherever an exact text match is not
    reliable, for example a search result card whose wrapping element has
    no useful class or attribute of its own, or the EDIT button whose
    visible label sits next to a material icon ligature in the same text
    node ("mode_edit\\nEDIT"), so no element's own text is ever exactly
    "EDIT" and get_by_text(..., exact=True) never matches anything."""
    target = re.sub(r"\s+", "", needle.upper())
    deadline = time.time() + timeout
    while time.time() < deadline:
        matches = page.evaluate(
            """(target) => {
                const els = document.querySelectorAll('a, div, li, button, span');
                const out = [];
                for (const el of els) {
                    if (el.children.length > 20) continue;
                    const t = (el.innerText || '').toUpperCase().replace(/\\s+/g, '');
                    if (!t.includes(target)) continue;
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) {
                        out.push({x: r.x, y: r.y, w: r.width, h: r.height, area: r.width * r.height});
                    }
                }
                out.sort((a, b) => a.area - b.area);
                return out.slice(0, 3);
            }""",
            target,
        )
        if matches:
            m = matches[0]
            page.mouse.click(m["x"] + m["w"] / 2, m["y"] + m["h"] / 2)
            return True
        time.sleep(0.3)
    return False


def _fill_purchase_price(page, value, timeout=6.0, label_keys=("PURCHASEPRICE",), marker="data-bb-purchase-price"):
    """Type value into the Purchase Price (£'s) field on the Stocklisting
    step. That field carries no name/id/placeholder, only a label div
    naming it, so the real input is located by finding the label's own
    position on screen and then the geometrically nearest visible text
    input to it, the same proven pattern _add_expense_items' own "Unit
    Net" field already uses (2026-08-23), rather than walking up the DOM
    from the label to the nearest ancestor holding an input (the original
    approach, found live 2026-09-05 to grab the wrong field once
    DealerKit added a "Purchase Invoice Ref #" field just above this one).

    A second bug found the same day, once the position based rewrite
    above still failed: `el.children.length <= 3` is not enough on its
    own to tell a real, short label apart from a huge ancestor wrapper.
    This page's own outer content container (everything on screen, the
    whole vehicle list plus the open modal) has only 1 to 3 DIRECT
    children of its own, so it passed that check too, and its own
    innerText, which is the WHOLE PAGE's text, genuinely contains the
    words "Purchase Price" buried somewhere inside it, thousands of
    characters in. Since it comes earlier in raw DOM order than the real,
    specific 20 character label and both report a real, nonzero bounding
    box, the search's own `break` fired on that huge wrapper first. A
    short text length cap, the same guard already used for the Unit Net
    field (`.length > 20` there), rules this class of false match out for
    good: a real field label is a handful of words, never a whole page.

    A THIRD bug found the same day, once both of the above were fixed and
    the right input was finally being typed into correctly (input.value
    confirmed landing before Save): DealerKit's Save request itself still
    carried purchase_cost: null. Captured live via a full request body
    dump. This is a Vue component whose own reactive form state only
    syncs from the raw DOM value on a genuine blur, not just on keystroke
    events, so typing via raw mouse and keyboard events (which never
    blurs the field before Save is clicked) leaves the framework's own
    internal model unaware the value ever changed, even though the DOM
    input.value itself reads back correctly, which is exactly why the
    earlier "landed" confirmation kept reporting success. Fixed by
    switching to Playwright's own locator.fill() plus an explicit
    .blur(), the same proven, already working pattern set_retail_price
    uses for this identical class of problem on a different DealerKit
    panel.

    Finds the input by marking it with a temporary, unique attribute
    (rather than raw mouse coordinates) so Playwright's own Locator API,
    not manual mouse and keyboard events, drives the actual fill and
    blur. Reads the DealerKit-visible value back afterward, via the same
    marker, to confirm the figure genuinely landed before ever clicking
    Save; never raises, a field that cannot be confirmed is left for
    Mark to fill by hand rather than risk a wrong or silently empty
    figure."""
    if value is None:
        return False
    number = int(value) if isinstance(value, float) and value.is_integer() else value
    marker = "data-bb-purchase-price"
    deadline = time.time() + timeout
    while time.time() < deadline:
        marked = page.evaluate(
            """([marker, labelKeys]) => {
                const els = document.querySelectorAll('div, span, label');
                let label = null;
                for (const el of els) {
                    if (el.children.length > 3) continue;
                    const raw = el.innerText || '';
                    if (raw.length > 40) continue;
                    const t = raw.toUpperCase().replace(/\\s+/g, '');
                    if (!labelKeys.some(k => t.includes(k))) continue;
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) { label = {x: r.x + r.width/2, y: r.y + r.height/2}; break; }
                }
                if (!label) return false;
                const inputs = Array.from(document.querySelectorAll('input[type=text]'));
                let best = null, bestDist = Infinity;
                for (const inp of inputs) {
                    const r = inp.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0) continue;
                    const cx = r.x + r.width/2, cy = r.y + r.height/2;
                    const dist = Math.hypot(cx - label.x, cy - label.y);
                    if (dist < bestDist) { bestDist = dist; best = inp; }
                }
                if (!best) return false;
                document.querySelectorAll('[' + marker + ']').forEach(e => e.removeAttribute(marker));
                best.setAttribute(marker, '1');
                return true;
            }""",
            [marker, list(label_keys)],
        )
        if marked:
            field = page.locator(f"[{marker}]")
            field.click()
            field.fill(str(number))
            field.blur()
            page.wait_for_timeout(300)
            landed = field.input_value()
            if str(number) in str(landed):
                return True
            return False
        time.sleep(0.25)
    return False


class DealerkitMissingMileage(RuntimeError):
    """Raised by add_due_in when it is about to create a genuinely NEW Due
    In record with no known mileage to give it (2026-08-26, Mark: "never
    create a listing in DK unless we have the mileage as this causes DK to
    estimate the mileage which is dangerous"). Only ever raised on a first
    push, an already existing record is returned early before this check
    ever runs ("this is only for the first push if its already present in
    DK dont over write"), matching every other hard gate in this project,
    play safe rather than let a wrong, DealerKit guessed mileage sit on a
    real business record. There is NO bypass (Steven 2026-08-27, "NEVER
    ALLOW Dealerkit to manually guess the mileage"): the old
    allow_missing_mileage escape hatch is gone, the fix is always to enter
    the real mileage on the purchases page first."""


def _default_supplier(purchase):
    """The supplier to attribute a car's price to on DealerKit, when
    nothing more specific applies: the row's own recorded supplier where
    one was set (2026-08-31, a hand added car's own supplier wins so it
    is never wrongly filed against Motorway), else CarWow or Motorway by
    platform. Shared by add_due_in's own new Bought From selection
    (2026-09-05) and push_purchase's "already exists, correct a price"
    branch, which used to compute this same fallback inline, on its own."""
    supplier = (purchase.get("supplier") or "").strip()
    if supplier:
        return supplier
    return "CarWow" if purchase.get("platform") == "Carwow" else "Motorway"


def _create_supplier_contact(page, company_name):
    """Creates a new DealerKit contact for a car's own supplier, via the
    real "+" button beside the Stocklisting step's "Bought From" field
    (2026-09-05, Steven: "the fix needs to be that it actually adds the
    supplier first ... so it works", so pressing Price on a genuinely new
    supplier never again needs someone to go and create the contact in
    DealerKit by hand first).

    The "+" button is found by its own visible text ("add", the material
    icon's own ligature) rather than a class, since a page wide class
    search for the same green button style landed on a COMPLETELY
    different, unrelated "+ Add Invoice Item" button once the Bought
    From field's own dropdown had already been opened (found live), and
    the two happened to share a screen position at that moment. Checked
    scoped by its own nearby text ("Bought From") instead, found reliable
    across several fresh attempts.

    Opening it reveals a real "New Contact" modal STACKED on top of the
    still-open Stocklisting one (both report the same z-index, so a
    naive "first visible dialog" check finds the WRONG one, the one
    underneath; scoped to `.contact-modal` specifically here). Company
    Name and Contact Type (set to "Supplier") are the two fields this
    actually needs; Referral Source LOOKS optional (Save stays enabled
    with it blank) but is not, DealerKit's own server refuses the save
    with a real 422 ("The referral source id field is required.") that
    the button's own enabled state gives no hint of, found by checking
    the real network response rather than trusting the click. "Other" is
    used there, a neutral choice for a contact recorded purely as a
    supplier, not a marketing lead.

    Verifies via the real save response (never a guess): raises loudly
    if DealerKit refuses it, for whatever reason, rather than reporting
    a false success and leaving the caller to type a price with nowhere
    real to attribute it."""
    pos = _evaluate_until(
        page,
        """() => {
            const btns = Array.from(document.querySelectorAll('button'));
            for (const b of btns) {
                const r = b.getBoundingClientRect();
                if (r.width === 0 || r.height === 0) continue;
                const icon = b.querySelector('i, .material-icons');
                const text = (b.innerText || icon?.textContent || '').trim();
                if (text !== 'add') continue;
                const node = b.closest('.row') || b.parentElement?.parentElement;
                const ctx = node ? (node.innerText || '') : '';
                if (!ctx.includes('Bought From')) continue;
                return {x: r.x + r.width / 2, y: r.y + r.height / 2};
            }
            return null;
        }""",
    )
    if not pos:
        raise RuntimeError("DealerKit: the Bought From + (add contact) button was not found.")
    page.mouse.click(pos["x"], pos["y"])
    page.wait_for_timeout(1000)

    def _field_pos(label):
        return page.evaluate(
            """(label) => {
                const modal = document.querySelector('.contact-modal');
                if (!modal) return null;
                const labels = Array.from(modal.querySelectorAll('.ui-select__label-text'));
                for (const l of labels) {
                    if (l.textContent.trim() !== label) continue;
                    const container = l.closest('.ui-select');
                    const r = container.getBoundingClientRect();
                    return {x: r.x + r.width / 2, y: r.y + r.height * 0.75};
                }
                return null;
            }""",
            label,
        )

    marked = page.evaluate(
        """() => {
            const modal = document.querySelector('.contact-modal');
            if (!modal) return false;
            const labels = Array.from(modal.querySelectorAll('.ui-textbox__label-text'));
            for (const l of labels) {
                if (l.textContent.trim() !== 'Company Name') continue;
                const input = l.closest('label').querySelector('input');
                input.setAttribute('data-bb-new-contact-name', '1');
                return true;
            }
            return false;
        }"""
    )
    if not marked:
        raise RuntimeError("DealerKit: the New Contact modal's Company Name field was not found.")
    field = page.locator("[data-bb-new-contact-name]")
    field.click()
    field.fill(company_name)
    field.blur()
    page.wait_for_timeout(300)

    type_pos = _field_pos("Contact Type")
    if not type_pos:
        raise RuntimeError("DealerKit: the New Contact modal's Contact Type field was not found.")
    page.mouse.click(type_pos["x"], type_pos["y"])
    page.wait_for_timeout(400)
    _click_dropdown_option(page, "Supplier")

    referral_pos = _field_pos("Referral Source")
    if not referral_pos:
        raise RuntimeError("DealerKit: the New Contact modal's Referral Source field was not found.")
    page.mouse.click(referral_pos["x"], referral_pos["y"])
    page.wait_for_timeout(400)
    _click_dropdown_option(page, "Other")

    result = {}

    def _on_save(resp):
        if "/api/contacts" in resp.url and resp.request.method == "POST":
            try:
                result["status"] = resp.status
                result["body"] = resp.json()
            except Exception:
                pass

    page.on("response", _on_save)
    save_pos = page.evaluate(
        """() => {
            const modal = document.querySelector('.contact-modal');
            if (!modal) return null;
            const footer = modal.querySelector('.ui-modal__footer');
            const btn = footer ? footer.querySelector('button') : null;
            if (!btn) return null;
            const r = btn.getBoundingClientRect();
            return {x: r.x + r.width / 2, y: r.y + r.height / 2};
        }"""
    )
    if not save_pos:
        page.remove_listener("response", _on_save)
        raise RuntimeError("DealerKit: the New Contact modal's Save button was not found.")
    page.mouse.click(save_pos["x"], save_pos["y"])
    page.wait_for_timeout(2000)
    page.remove_listener("response", _on_save)

    if result.get("status") not in (200, 201):
        raise RuntimeError(
            f"DealerKit refused to create the {company_name!r} contact: "
            f"{json.dumps(result.get('body'))[:300] if result.get('body') else 'no response captured'}."
        )



def _select_bought_from(page, contact_name):
    """Picks an EXISTING contact in the Stocklisting step's own "Bought
    From" field (2026-09-05, Steven, testing by hand: "you need to add a
    supplier when adding a car before the purchase price will save",
    found after three other real bugs in this same day's session had
    already been fixed and Purchase Price still would not persist).

    "Bought From" opens the same .ui-select component family as the Add
    to Funding modal's own fields (found live, so _click_ui_select_field,
    already proven for that modal, opens it correctly), but this is a
    live CONTACT SEARCH, not a plain static list: opening it reveals a
    real `.ui-select__search-input` (placeholder "Start typing to
    search") with NO options showing at all until something is typed, so
    the already existing _click_dropdown_option (built for the Funding
    modal's own plain, always-populated list) finds nothing here. Once a
    name is typed, each real result is a multi-line
    `li.ui-select-option` (contact name, then its postcode, then its own
    category, e.g. "Motorway\nEC1A 2BN\nSupplier"), never a bare
    single-line match, so the option is matched on its FIRST LINE
    (the contact's own name) rather than the whole cell's text.

    Never creates a new contact (the green + button beside this field is
    left alone, same as it always has been); raises if contact_name has
    no matching result after typing, so the caller can fall back to
    leaving Purchase Price for a person to enter rather than typing a
    figure that will only look saved, never genuinely persisting."""
    def _try_pick():
        _click_ui_select_field(page, "Bought From")
        search_box = _evaluate_until(
            page,
            """() => {
                const els = document.querySelectorAll('.ui-select__search-input');
                for (const el of els) {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x, y: r.y, w: r.width, h: r.height};
                }
                return null;
            }""",
        )
        if not search_box:
            raise RuntimeError("DealerKit: the Bought From search box was not found.")
        page.mouse.click(search_box["x"] + search_box["w"] / 2, search_box["y"] + search_box["h"] / 2)
        page.keyboard.type(contact_name)
        return _evaluate_until(
            page,
            """(name) => {
                const opts = Array.from(document.querySelectorAll('.ui-select-option'));
                for (const el of opts) {
                    const norm = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
                    const first = (el.innerText || '').split('\\n')[0].trim();
                    // "Carwow" on DealerKit, "CarWow" here: the same
                    // contact (2026-09-16, the reason a price could be
                    // left untyped at creation).
                    if (norm(first) !== norm(name)) continue;
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
                return null;
            }""",
            contact_name,
            timeout=4.0,
        )

    pos = _try_pick()
    if not pos:
        # Not found among existing contacts (2026-09-05, Steven: "the fix
        # needs to be that it actually adds the supplier first ... so it
        # works"): create it as a real DealerKit contact. DealerKit auto
        # selects a freshly created contact straight into the Bought From
        # field itself once Save succeeds (confirmed live), so the field
        # is checked directly rather than reopening the search and
        # picking again, which would otherwise risk a real race: a fresh
        # contact was found live to sometimes not yet be searchable
        # through this exact typeahead the instant after it is created,
        # while the field it was just saved into already shows it.
        _create_supplier_contact(page, contact_name)
        page.wait_for_timeout(500)
        selected = page.evaluate(
            """(name) => {
                const labels = Array.from(document.querySelectorAll('.ui-select__label-text'));
                for (const l of labels) {
                    if (l.textContent.trim() !== 'Bought From') continue;
                    const container = l.closest('.ui-select');
                    const r = container.getBoundingClientRect();
                    if (r.width === 0) continue;
                    const disp = container.querySelector('.ui-select__display-value');
                    return !!disp && disp.textContent.trim() === name;
                }
                return false;
            }""",
            contact_name,
        )
        if selected:
            return
        # Not auto selected this time, fall back to the same search and
        # pick used for an already existing contact.
        pos = _try_pick()
    if not pos:
        raise RuntimeError(f"DealerKit: no Bought From contact named {contact_name!r} was found.")
    page.mouse.click(pos["x"], pos["y"])
    page.wait_for_timeout(500)

def _reply_words(resp):
    """One line on a DealerKit reply that said no: the status, the path
    and the first words of the body, for a failure message."""
    try:
        path = urlparse(resp.url).path
    except Exception:
        path = resp.url
    try:
        body = " ".join((resp.text() or "").split())[:160]
    except Exception:
        body = ""
    return f"{resp.status} on {path}" + (f": {body}" if body else "")


def _modal_words(page):
    """What the open DealerKit window is showing right now, in DealerKit's
    own words: its error or warning lines when it has any, otherwise the
    whole window's text. Never raises, an empty string when nothing can
    be read."""
    try:
        return page.evaluate("""() => {
          const opens = document.querySelectorAll('.ui-modal.is-open');
          const m = opens.length ? opens[opens.length - 1] : document.body;
          const picks = m.querySelectorAll('[role=alert], .alert, .error, .invalid-feedback, .text-danger, .has-error, [class*=error], [class*=danger], [class*=warning]');
          const parts = [];
          for (const el of picks) { const t = (el.innerText || '').trim(); if (t && !parts.includes(t)) parts.push(t); }
          if (parts.length) return parts.join(' ');
          return (m.innerText || '').trim();
        }""") or ""
    except Exception:
        return ""


def _no_confirmation_message(reg, said, refusals, cap=300):
    """The sentence a send raises when DealerKit neither created the car
    nor said it already had it (2026-09-06). Carries DealerKit's own
    words, from the window and from its server, so Dealer OS's timeline
    says why instead of "the page may have changed"."""
    said = " ".join((said or "").split())[:cap]
    replies = "; ".join(r for r in (refusals or []) if r)[:cap]
    msg = f"DealerKit did not add {reg} after its registration was submitted."
    if said:
        msg += f" The Add Vehicle window says: {said}"
    if replies:
        msg += f" DealerKit's server replied: {replies}"
    if not said and not replies:
        msg += " No reason was shown. The page may have changed."
    return msg


def add_due_in(page, reg, purchase=None, type_price=True):
    """Create the vehicle as a Due In stock record from its registration
    alone. Returns {"created": True} for a genuinely new record, or
    {"already_exists": True} when DealerKit already has this reg in its
    stocklist (real state, not an error, someone on the team may have added
    it by hand already). Raises if neither is confirmed.

    purchase is used two separate ways, deliberately never coupled: its
    mileage always gates whether a genuinely new record may be created at
    all (see DealerkitMissingMileage below), regardless of what is being
    pushed, since photos or documents alone still need a real vehicle_id
    to upload to and so still create the record; type_price (2026-08-26,
    a real bug caught building the mileage gate, a "photos only" push
    used to pass purchase=None specifically to suppress typing the price,
    which would have also suppressed the mileage check on a car that
    genuinely had one on file) separately controls whether purchase's own
    price actually gets typed into the Purchase Price field, matching
    push_purchase's own "only price gates the typed figure".

    Raises DealerkitMissingMileage before ever opening the Add Vehicle
    modal when this would be a genuinely new record and purchase carries
    no mileage. No bypass exists, see the class docstring above.

    When purchase is given and this is a genuine new record, types
    BidBrain's own already computed purchase price (winning_bid or price)
    into the Purchase Price field before Save. DealerKit has its own live
    Motorway integration that can fill this in on its own (proven live
    twice, see CLAUDE.md), but only ever seen firing on a session Mark
    drove himself by hand, never on an automated one (three separate
    attempts, one waited on for 15+ minutes, all blank), so an automated
    push types the figure in itself rather than leave it sitting empty
    forever. Retail Price is left untouched, that is Mark's own market
    call. Nothing else on the Stocklisting step is touched either (Bought
    From needs matching or creating a DealerKit contact, not attempted
    here). When purchase is not given, or its price could not be confirmed
    landing in the field, just clicks Skip, same as leaving it for Mark.

    Checks DealerKit's own stocklist for this reg FIRST, before ever
    attempting to create anything (2026-08-24, found live the hard way: a
    Carwow purchases batch created 13 genuine DUPLICATE Due In records
    for cars DealerKit already held as a completed, disposed sale,
    life_cycle_status 25). The bug was trusting DealerKit's own POST
    /api/stocklist response alone to detect "this reg already exists" (a
    422): that check IS real and does fire for a reg still active in some
    other stage, but DealerKit does NOT return it for an already disposed
    one, it just creates a second record instead, no error at all. A
    reg lookup first (_find_vehicle_id_by_reg, already used elsewhere in
    this module) closes that gap regardless of the vehicle's own status,
    and is also simply cheaper: a genuine repeat call for an already
    pushed reg no longer even opens the Add Vehicle modal."""
    existing_id = _find_vehicle_id_by_reg(page, reg, other_plate(purchase))
    if existing_id:
        return {"created": False, "already_exists": True, "purchase_price_filled": False,
                "vehicle_id": existing_id}

    if not (purchase and purchase.get("mileage")):
        raise DealerkitMissingMileage(
            f"{reg} has no mileage on file. Creating it on DealerKit now would let "
            "DealerKit estimate the mileage itself, which is never allowed. Enter "
            "the real mileage on the purchases page first."
        )

    page.goto(f"{DEALERKIT_BASE}/vehicles", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2000)

    if not _click_visible(page, page.locator("a.pill-green")):
        raise RuntimeError(
            "DealerKit: the + Vehicle button was not found on the vehicles list. "
            "The page may have changed."
        )
    page.wait_for_timeout(1200)

    reg_el, reg_box = _visible(page.get_by_placeholder("e.g. EO67 GJV"))
    if not reg_box:
        raise RuntimeError(
            "DealerKit: the registration field was not found in the Add Vehicle modal. "
            "The page may have changed."
        )
    page.mouse.click(reg_box["x"] + reg_box["width"] / 2, reg_box["y"] + reg_box["height"] / 2)
    page.keyboard.type(reg)
    page.wait_for_timeout(600)

    # The mileage goes in HERE, beside the registration on the very same
    # "Add Vehicle To Stock" modal (Steven 2026-09-05: "the mileage should
    # be entered on the add vehicle to stock modal alongside the reg at
    # this very point"), so DealerKit never writes its own guessed figure
    # onto the new record in the first place. The field is the one input
    # whose placeholder reads "e.g. 43000 (Optional)". Verified after the
    # create through the API (mileage_typed below); a later mileage log
    # entry is only ever needed when this did not land.
    mileage_typed = False
    miles = (purchase or {}).get("mileage")
    if miles:
        mil_el, mil_box = _visible(page.locator('.ui-modal.is-open input[placeholder*="43000"]'), timeout=4.0)
        if mil_box:
            page.mouse.click(mil_box["x"] + mil_box["width"] / 2, mil_box["y"] + mil_box["height"] / 2)
            page.keyboard.type(str(int(miles)))
            page.wait_for_timeout(400)
            mileage_typed = True

    result = {"created": False, "already_exists": False, "mileage_typed": mileage_typed}
    refusals = []

    def _on_response(resp):
        if "/api/stocklist" in resp.url and resp.request.method == "POST":
            if resp.status == 422:
                result["already_exists"] = True
            elif resp.status in (200, 201):
                result["created"] = True
                # The new vehicle's own id (2026-09-05, found live: the
                # created branch never captured this at all, so a Save
                # click straight after had no id to verify the price
                # against, see the fix on that Save click below).
                try:
                    body = resp.json()
                    result["vehicle_id"] = body.get("id") or (body.get("data") or {}).get("id")
                except Exception:
                    pass
            else:
                refusals.append(_reply_words(resp))
        elif "/api/" in resp.url and resp.status >= 400:
            # DealerKit's own registration lookup saying no (2026-09-06,
            # found live on TE57DOS: two sends from Dealer OS ended here
            # with nothing created and nothing said about why).
            refusals.append(_reply_words(resp))

    page.on("response", _on_response)
    try:
        if not _click_visible_text(page, "Next", exact=True):
            raise RuntimeError(
                f"DealerKit: the Next button was not found after typing {reg}."
            )
        # Wait for DealerKit's own answer, up to 15 s, rather than a fixed
        # 3 s (2026-09-06): a slow reply after a fresh login used to read
        # as "no confirmation" while the record may still have landed.
        deadline = time.time() + 15
        while time.time() < deadline and not (result["created"] or result["already_exists"]):
            page.wait_for_timeout(500)
        if result["created"]:
            page.wait_for_timeout(3000)
    finally:
        page.remove_listener("response", _on_response)

    if not result["created"] and not result["already_exists"]:
        # Before calling it a failure, look the plate up: a create that
        # answered after the wait is a record all the same.
        late_id = None
        try:
            late_id = _find_vehicle_id_by_reg(page, reg, other_plate(purchase))
        except DealerkitSessionExpired:
            raise
        except Exception:
            late_id = None
        if late_id:
            result["already_exists"] = True
            result["vehicle_id"] = late_id
        else:
            raise RuntimeError(_no_confirmation_message(reg, _modal_words(page), refusals))

    if result["created"] and mileage_typed and result.get("vehicle_id"):
        try:
            result["mileage_on_record"] = _vehicle_mileage(page, result["vehicle_id"])
        except Exception:
            result["mileage_on_record"] = None

    if result["created"]:
        # The Stocklisting modal (Purchase Details etc) is now open. Nothing
        # on it is required (a Skip sits next to Save). Fill Purchase Price
        # from BidBrain's own figures when we have one and it is confirmed
        # to actually land (see _fill_purchase_price and the docstring
        # above); otherwise just Skip, same as leaving it for Mark.
        price = None
        if purchase and type_price:
            # car_price first (2026-08-27): the canonical figure from
            # db.list_purchases, the winning bid less any chips, so a car
            # chipped before its first push is created at the real price.
            price = purchase.get("car_price")
            if price is None:
                price = purchase.get("winning_bid") or purchase.get("price")
        # A FOURTH thing found live 2026-09-05, by Steven himself, testing
        # by hand after all three earlier bugs in this same session were
        # fixed and the price STILL would not save: DealerKit refuses to
        # persist Purchase Price at all unless a "Bought From" contact is
        # also set on the record first. Confirmed live: with no contact,
        # Save produces no write whatsoever (see _fill_purchase_price's
        # own history); with one picked, it saves. So a contact is now
        # selected here, before the price is ever typed, using the same
        # supplier the "already exists, correct a price" branch further
        # down already falls back to (the row's own recorded supplier,
        # else Motorway or CarWow by platform). If the named contact does
        # not exist in DealerKit yet, this is left alone (no attempt to
        # create one, same as always) and the price is not typed either,
        # since typing it would only look successful without saving.
        bought_from_set = False
        price_reason = None
        if price is not None:
            supplier = _default_supplier(purchase)
            # Two attempts, not one (2026-09-05): DealerKit's own SPA is
            # already well documented elsewhere in this file as having
            # genuinely variable transition timing run to run, and this
            # step was found live to occasionally fail on a first try
            # (about 1 in 6 real attempts) while succeeding cleanly on
            # every repeat, the same "retry once, then accept" discipline
            # already used throughout this module (_dealerkit_with_relogin,
            # _fill_purchase_price, every _try_save loop) rather than
            # chase a single, precise root cause on a system this project
            # has never found to be fully deterministic.
            last_err = None
            for _ in range(2):
                try:
                    _select_bought_from(page, supplier)
                    bought_from_set = True
                    break
                except Exception as e:
                    last_err = e
                    continue
            if not bought_from_set:
                # Said in words, never swallowed (2026-09-16, ND66XYJ and
                # FJ67WFR: two sends lost their price here and the log
                # only said "no confident figure").
                price_reason = (f"the Bought From contact {supplier!r} could not be picked"
                                + (f", {' '.join(str(last_err).split())[:200]}" if last_err else ""))
                price = None
        # The seller's finance settlement is never typed here (Steven
        # 2026-09-16: "the settlement figure never has to go to dealerkit
        # ever. it is always handled at the point of collection or before").
        typed = _fill_purchase_price(page, price)
        filled = False
        if price is not None and not typed:
            price_reason = "the Purchase Price box was not found on DealerKit's form"
        if typed:
            # Clicking Save is not proof it saved (2026-09-05, found live
            # on a real fresh S17OUG test: the field showed the right
            # number, Save was clicked with no error, yet DealerKit's own
            # API held purchase_cost null moments later). This step's own
            # click used to trust a single, unverified click the way
            # set_retail_price's own docstring already warns against on a
            # page carrying several same-worded buttons, only one of them
            # real. Now tries every matching Save button and only calls it
            # filled once the API confirms the figure genuinely landed,
            # retrying once, the same discipline set_retail_price already
            # uses. Never raises on a failure to save here, same as a
            # figure that could not be typed at all: left for a person to
            # enter by hand rather than risk the whole record creation on
            # a step that is not required.
            target = int(price) if isinstance(price, float) and float(price).is_integer() else price
            new_id = result.get("vehicle_id")
            for _ in range(2):
                btns = page.locator("button", has_text="Save")
                clicked = False
                for i in range(btns.count()):
                    try:
                        btns.nth(i).click(timeout=2000)
                        clicked = True
                        break
                    except Exception:
                        continue
                if not clicked:
                    break
                page.wait_for_timeout(1500)
                if new_id:
                    data = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{new_id}")
                    if data.get("purchase_cost") is not None:
                        filled = True
                        break
                else:
                    # No id to verify against (the create response's own
                    # shape did not carry one this time); a click that did
                    # not error is the best signal available, matching the
                    # old behaviour rather than blocking on something we
                    # cannot check.
                    filled = True
                    break
        if typed and not filled:
            price_reason = "the figure was typed but DealerKit did not save it"
        if not filled:
            _click_visible_text(page, "Skip", exact=True, timeout=6.0)
            page.wait_for_timeout(1000)
        result["purchase_price_filled"] = filled
        result["price_reason"] = price_reason
        if price_reason:
            print(f"  Purchase price at creation: not saved, {price_reason}; it goes on the purchase expense next")
    else:
        result["purchase_price_filled"] = False
        result["price_reason"] = None

    return result


def set_retail_price(page, vehicle_id, price):
    """Set DealerKit's own Retail Price on the vehicle's Pricing edit
    panel (2026-08-26, Mark: "the Retail Est is the retail price that
    should be mapped to DK", confirmed: "BB calculates the retail est
    based on cazana 115%, this is the retail price that is then used in
    DK"). This reverses the earlier, deliberate "leave Retail Price for
    Mark to type in by hand" decision recorded elsewhere in this project,
    a real policy change confirmed explicitly before building this.

    Built against the real live page (vehicle 336, GU68XPP): Edit
    Vehicle > Pricing opens a panel whose big central price display is
    genuinely a plain <input type=text placeholder="£RETAIL">, found by
    searching for that exact placeholder text once a plain "RETAIL" text
    search only ever found the unrelated AutoTrader valuation figure
    sitting elsewhere on the same panel; the label only exists as an
    input placeholder here, never as real text content anywhere in the
    DOM. Typing into it fires a real, harmless GET to DealerKit's own
    pricing-insights endpoint (the live "AutoTrader price position"
    indicator recalculating), proof the field itself is genuinely wired
    up before Save is ever clicked.

    Saving needs Playwright's own real .click() with its full
    actionability checks, not this module's usual _click_visible_text
    (a raw mouse click at a bounding box centre): this panel renders
    SEVEN "Save Changes" buttons at once, and unlike every other
    duplicate-button case elsewhere in this file, more than one of the
    hidden ones still reports a real, non-zero bounding box, the one
    thing _click_visible_text's own visibility check relies on, so it
    silently reported success while actually clicking a dead duplicate
    (confirmed live: no PATCH request at all reached DealerKit's API
    that way). Tries each match in turn with a real, strict .click()
    until one genuinely succeeds. Verifies via the API afterward,
    retrying once, raising loudly rather than reporting a false success,
    the same lesson every other DealerKit write in this project has
    already learned the hard way."""
    # The API first (2026-09-07), the panel only when DealerKit would not
    # take the call. Returns "api" or "screen" so the timeline can say
    # which way it went; every caller before today only tested for truth.
    if set_retail_price_api(page, vehicle_id, price):
        return "api"
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)
    if not _click_containing(page, "EDIT"):
        raise RuntimeError(f"DealerKit: the Edit button was not found (vehicle {vehicle_id}).")
    page.wait_for_timeout(1500)
    if not _click_visible_text(page, "Pricing", exact=True, within=".ui-modal.is-open"):
        raise RuntimeError(f"DealerKit: the Pricing tile was not found (vehicle {vehicle_id}).")
    page.wait_for_timeout(1500)

    field, box = _visible(page.get_by_placeholder("£RETAIL"))
    if not box:
        raise RuntimeError(f"DealerKit: the Retail Price field was not found (vehicle {vehicle_id}).")
    target = int(round(price))
    field.click()
    field.fill(str(target))
    field.blur()
    page.wait_for_timeout(500)

    def _try_save():
        btns = page.locator("button", has_text="Save Changes")
        for i in range(btns.count()):
            try:
                btns.nth(i).click(timeout=2000)
                return True
            except Exception:
                continue
        return False

    for _ in range(2):
        if _try_save():
            page.wait_for_timeout(2000)
            data = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}")
            if data.get("retail_price") == target:
                return "screen"
    raise RuntimeError(f"DealerKit: Retail Price did not actually save for vehicle {vehicle_id}.")


def _delivery_date_matches(stored_iso, date_iso):
    """DealerKit stores delivery_on as UTC midnight of the LOCAL (UK) day
    (confirmed live 2026-08-26: setting it to "2026-08-28" read back as
    "2026-08-27T23:00:00+00:00" during BST), so a plain stored_iso[:10]
    comparison is off by a day whenever the UK is on summer time. Adding
    a small fixed buffer before taking the date is safe regardless of
    which offset (GMT +0 or BST +1) applies, since the UK is never more
    than an hour ahead of UTC, unlike branching on the stored hour, which
    would need to know the exact DST boundary date to get right."""
    if not stored_iso:
        return False
    try:
        dt = datetime.datetime.fromisoformat(stored_iso.replace("Z", "+00:00"))
    except ValueError:
        return False
    return (dt + datetime.timedelta(hours=4)).date().isoformat() == date_iso


def set_delivery_date(page, vehicle_id, date_iso):
    """DealerKit's own "Due In / In-stock On" date, on the vehicle's
    Miscellaneous edit panel (2026-08-26, Mark found it live: "on the car
    listing page go to edit then miscellaneous... the due in / in-stock
    date it there"). Its internal API field is delivery_on, the exact
    field an earlier session (see CLAUDE.md, 2026-08-23) concluded had NO
    editable UI path anywhere, checked at the time under Description,
    Additional Details, and this same Miscellaneous tab's Location and
    Delivery Details section, and even tried once as a raw API write,
    refused by Claude Code's own safety check as untested and invasive.
    That conclusion was wrong, or the field is newer, either way it is
    real and working now, found by Mark navigating the live UI himself
    rather than guessed at again.

    date_iso should be BidBrain's own collection_date (Motorway's real
    "Collected"/"Collection date" timeline read, or Carwow's own
    "Collection date" card, see motorway.read_collection_date and
    carwow.read_collection_date), the date the car is due at the
    dealership. No op, returns False without touching anything, if the
    stored date already matches (see _delivery_date_matches).

    Built the same way as every other DealerKit date write in this
    module: a real UI flow, never a raw API PATCH. Opens the field via
    _click_ui_select_field (the same .ui-datepicker__label-text
    component the Add to Funding modal's own date fields use) and picks
    the day via _pick_calendar_date. The Miscellaneous panel's own SAVE
    button is one of 24 elements on the page sharing that exact text,
    almost all hidden duplicates from other Edit Vehicle tiles rendered
    off screen (the same shape already seen and solved for Pricing's own
    "Save Changes", 7 of which report a live but dead bounding box), so
    every match is tried in turn with a real .click() until one
    genuinely succeeds, then verified via a fresh API read, retrying
    once, raising loudly rather than reporting a false success."""
    existing = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}")
    if _delivery_date_matches(existing.get("delivery_on"), date_iso):
        return False

    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)
    if not _click_containing(page, "EDIT"):
        raise RuntimeError(f"DealerKit: the Edit button was not found (vehicle {vehicle_id}).")
    page.wait_for_timeout(1500)
    if not _click_visible_text(page, "Miscellaneous", exact=True, within=".ui-modal.is-open"):
        raise RuntimeError(f"DealerKit: the Miscellaneous tile was not found (vehicle {vehicle_id}).")
    page.wait_for_timeout(1500)

    _click_ui_select_field(page, "Due In / In-stock On")
    page.wait_for_timeout(800)
    _pick_calendar_date(page, date_iso, "Due In / In-stock On")

    def _try_save():
        btns = page.locator("button", has_text="SAVE")
        for i in range(btns.count()):
            try:
                btns.nth(i).click(timeout=2000)
                return True
            except Exception:
                continue
        return False

    for _ in range(2):
        if _try_save():
            page.wait_for_timeout(2000)
            data = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}")
            if _delivery_date_matches(data.get("delivery_on"), date_iso):
                return True
    raise RuntimeError(f"DealerKit: Due In / In-stock On did not actually save for vehicle {vehicle_id}.")


def wait_for_dealerkit_financials(page, vehicle_id, timeout=30.0, poll_every=3.0):
    """Poll DealerKit's own stocklist API (not the UI, which masks these
    figures behind a Show toggle) for up to timeout seconds, waiting for
    its own Motorway integration to fill in purchase_cost and retail_price
    after a fresh due in creation. Returns (purchase_cost, retail_price),
    either or both None if DealerKit had not filled them in by the
    deadline (a real, normal outcome, not every reg matches a source DealerKit
    recognises; the record itself is unaffected either way)."""
    deadline = time.time() + timeout
    purchase_cost = retail_price = None
    while time.time() < deadline:
        captured = []

        def _on_response(r, _captured=captured):
            if f"/api/stocklist/{vehicle_id}" in r.url and r.request.method == "GET":
                _captured.append(r)

        page.on("response", _on_response)
        try:
            page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
                      wait_until="domcontentloaded", timeout=45000)
            page.wait_for_timeout(2500)
        finally:
            page.remove_listener("response", _on_response)

        for r in captured:
            try:
                data = r.json()
            except Exception:
                continue
            purchase_cost = data.get("purchase_cost")
            retail_price = data.get("retail_price")
            break
        if purchase_cost is not None or retail_price is not None:
            return purchase_cost, retail_price
        time.sleep(poll_every)
    return purchase_cost, retail_price


def delete_expense(page, vehicle_id, match_text, confirm=True):
    """Deletes a whole expense record (every line item on it, not just
    one) via the vehicle's own Edit > Expenses list and that specific
    expense's own "More" menu. match_text is real, distinguishing text
    from the expense's own row on the plain list (for example
    "CarWow" plus "Delivery Charge" for a standalone one item expense,
    never "Buyers Premium" in the same search so it does not also match a
    fuller combined expense), used to find and open the right one; raises
    if none or more than one row matches, never guesses which expense a
    human meant.

    Built 2026-08-24 to remove a real duplicate: WX16AUE had two separate
    CarWow expenses, one a standalone single Delivery Charge item left
    over from however these older records were first entered, found live
    by Mark ("if you find record with duplicate info WX16AUE delete it").
    Real reconnaissance found two dead ends before this worked: the item's
    own kebab menu (Edit / Duplicate / Delete) DOES let a single line item
    be deleted, but only from the browser's own local form state, a
    "Save & Approve" afterward silently did not persist an expense
    emptied down to zero items (DealerKit likely refuses that, an expense
    needs at least one line); this function goes through the EXPENSE's
    own separate "More" button (bottom left of the Edit Expense modal,
    distinct from the item row's own more_vert kebab, matched on its
    real "more_horiz" icon text so it is never confused with the item
    one) instead, whose own Delete removes the whole record in one action
    and needs no separate Save, verified live: item 752 and expense 571
    were both confirmed gone via the API immediately after, no second
    save step needed. Triggers a real native window.confirm ("Are you
    sure you want to delete this expense?"), handled the same way as
    delete_vehicle's own dialog. Verifies via the API afterward that the
    matched expense's own real numeric id (read from the modal's own
    "Edit Expense #NNN" heading before deleting) no longer has any items,
    the same lesson as every other write in this module, a click
    reporting success is not proof something actually happened."""
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2000)
    _open_edit_tile(page, vehicle_id, "Expenses")
    page.wait_for_timeout(1500)

    needles = [t.strip().lower() for t in match_text if t.strip()] if isinstance(match_text, (list, tuple)) \
        else [match_text.strip().lower()]
    matches = page.evaluate(
        """(needles) => {
            const els = Array.from(document.querySelectorAll('div'));
            const out = [];
            for (const el of els) {
                if (el.children.length > 4) continue;
                const t = (el.textContent || '').trim().toLowerCase();
                if (!t) continue;
                if (needles.every(n => t.includes(n))) {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) out.push({x: r.x, y: r.y, w: r.width, h: r.height});
                }
            }
            return out;
        }""",
        needles,
    )
    if len(matches) != 1:
        raise RuntimeError(
            f"DealerKit: expected exactly one expense row matching {match_text!r} "
            f"(vehicle {vehicle_id}), found {len(matches)}."
        )
    row = matches[0]
    pencil_pos = page.evaluate(
        """(row) => {
            const el = document.elementFromPoint(row.x + row.w / 2, row.y + row.h / 2);
            let node = el;
            for (let i = 0; i < 5 && node; i++) {
                const btn = node.querySelector && node.querySelector('button, svg, i.material-icons');
                if (btn) {
                    const r = btn.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
                node = node.parentElement;
            }
            return null;
        }""",
        row,
    )
    if not pencil_pos:
        raise RuntimeError(f"DealerKit: could not find the edit control for the matched expense row (vehicle {vehicle_id}).")
    page.mouse.click(pencil_pos["x"], pencil_pos["y"])
    page.wait_for_timeout(1500)

    title = page.evaluate(
        """() => {
            const h = Array.from(document.querySelectorAll('*'))
                .find(e => e.children.length === 0 && (e.textContent || '').includes('Edit Expense #'));
            return h ? h.textContent.trim() : null;
        }"""
    )
    m = re.search(r"Edit Expense #(\d+)", title or "")
    if not m:
        raise RuntimeError(f"DealerKit: opening the matched expense did not land on its own Edit Expense modal (vehicle {vehicle_id}).")
    expense_id = int(m.group(1))

    more_pos = page.evaluate(
        """(expense_id) => {
            const modal = Array.from(document.querySelectorAll('[role=dialog]'))
                .find(d => (d.innerText || '').includes('Edit Expense #' + expense_id));
            if (!modal) return null;
            const els = Array.from(modal.querySelectorAll('button'));
            for (const el of els) {
                if ((el.textContent || '').includes('more_horiz')) {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }""",
        expense_id,
    )
    if not more_pos:
        raise RuntimeError(f"DealerKit: the expense's own More button was not found (expense {expense_id}, vehicle {vehicle_id}).")
    page.mouse.click(more_pos["x"], more_pos["y"])
    page.wait_for_timeout(1000)

    accepted = {"flag": False}

    def _on_dialog(d):
        if confirm:
            accepted["flag"] = True
            d.accept()
        else:
            d.dismiss()

    page.on("dialog", _on_dialog)
    try:
        if not _click_visible_text(page, "Delete", exact=True):
            raise RuntimeError(f"DealerKit: the expense's own Delete option was not found (expense {expense_id}, vehicle {vehicle_id}).")
        page.wait_for_timeout(2000)
    finally:
        page.remove_listener("dialog", _on_dialog)

    if not confirm:
        return False
    if not accepted["flag"]:
        raise RuntimeError(f"DealerKit: the delete confirmation dialog was not seen (expense {expense_id}, vehicle {vehicle_id}).")

    after = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems&with[]=expenseItems.expense",
        timeout=20000).json()
    still_there = any((it.get("expense") or {}).get("id") == expense_id for it in (after.get("expense_items") or []))
    if still_there:
        raise RuntimeError(f"DealerKit: expense {expense_id} still has items after delete, it may not have saved (vehicle {vehicle_id}).")
    return True


def open_vehicle_overview(page, reg):
    """Search DealerKit for reg and open its own record, returning the
    numeric vehicle id from the resulting /vehicles/<id>/overview URL."""
    page.goto(f"{DEALERKIT_BASE}/vehicles", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(1500)

    search_box, box = _visible(page.locator("input[placeholder=Search]"))
    if not box:
        raise RuntimeError("DealerKit: the search box was not found.")
    page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
    page.keyboard.type(reg)
    page.wait_for_timeout(2500)

    if not _click_containing(page, reg):
        raise RuntimeError(f"DealerKit: {reg} did not appear in search results.")
    page.wait_for_timeout(2500)

    m = re.search(r"/vehicles/(\d+)/overview", page.url)
    if not m:
        raise RuntimeError(
            f"DealerKit: opening {reg} did not land on its overview page "
            f"(landed on {page.url} instead)."
        )
    return int(m.group(1))


def delete_vehicle(page, vehicle_id, confirm=True):
    """Permanently deletes a vehicle record via its own kebab menu (found
    live 2026-08-24, real reconnaissance: the top right more_vert opens a
    small menu with Add to list / Write-Off / Delete, real DIVs of class
    .ui-menu-option__text, not semantic menu items, so not findable via a
    role or tag based search). Delete triggers a genuine native
    window.confirm ("Are you sure you want to delete this vehicle?"), the
    same mechanism already proven for the Funding Settle flow, handled the
    same way (a real page.on("dialog", ...) listener, never the auto
    dismiss a plain click would otherwise get). confirm=False stops right
    before accepting the dialog, for a dry run; the dialog is always
    dismissed either way in that case, nothing deleted. Verifies via the
    API afterward that the vehicle genuinely no longer resolves, the same
    lesson as every other write in this module: a click reporting success
    is not proof something actually happened.

    Built 2026-08-24 specifically to clean up 13 real duplicate "Due In"
    records a Carwow purchases batch push accidentally created: add_due_in
    only ever checked DealerKit's own POST /api/stocklist response for a
    422 to detect "this reg already exists", which DealerKit does return
    for a car still active in some other stage, but NOT for one already
    disposed of (life_cycle_status 25, a genuinely completed sale), so a
    repeat push for an already sold reg silently created a second,
    spurious Due In record alongside the real, untouched sold one rather
    than being refused. See push_purchase's own updated docstring for the
    real fix (a reg lookup before ever attempting to create)."""
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2000)

    kebab_pos = page.evaluate(
        """() => {
            const els = Array.from(document.querySelectorAll('button, a, i, span'));
            for (const el of els) {
                if ((el.textContent || '').trim() === 'more_vert') {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0 && r.y < 200) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if not kebab_pos:
        raise RuntimeError(f"DealerKit: the vehicle's own menu (top right) was not found (vehicle {vehicle_id}).")
    page.mouse.click(kebab_pos["x"], kebab_pos["y"])
    page.wait_for_timeout(1000)

    del_pos = page.evaluate(
        """() => {
            const els = Array.from(document.querySelectorAll('.ui-menu-option__text'));
            for (const el of els) {
                if ((el.textContent || '').trim().toLowerCase() === 'delete') {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if not del_pos:
        raise RuntimeError(f"DealerKit: the Delete menu option was not found (vehicle {vehicle_id}).")

    accepted = {"flag": False}

    def _on_dialog(d):
        if confirm:
            accepted["flag"] = True
            d.accept()
        else:
            d.dismiss()

    page.on("dialog", _on_dialog)
    try:
        page.mouse.click(del_pos["x"], del_pos["y"])
        page.wait_for_timeout(2000)
    finally:
        page.remove_listener("dialog", _on_dialog)

    if not confirm:
        return False
    if not accepted["flag"]:
        raise RuntimeError(f"DealerKit: the delete confirmation dialog was not seen (vehicle {vehicle_id}).")

    resp = page.context.request.get(f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}", timeout=20000)
    if resp.status < 400:
        try:
            still_there = resp.json().get("id") == vehicle_id
        except Exception:
            still_there = True
        if still_there:
            raise RuntimeError(f"DealerKit: vehicle {vehicle_id} still resolves after delete, it may not have saved.")
    return True


def delete_contact(page, contact_id, confirm=True):
    """Permanently deletes a DealerKit contact from its own real page
    (`/contacts/{id}`, found live 2026-09-05, Steven: "to delete a contact
    inside dealerway you go to contacts, click the contacts name then
    click delete"), a real, plain "DELETE" button, not a kebab menu the
    way a vehicle's own delete is.

    The button's own visible text shares one node with the material icon
    ligature ("delete\nDELETE"), the same "no element's text is ever the
    plain word alone" pattern already seen elsewhere in this file (the
    Edit button), so it is matched by a CONTAINS check, not an exact one.

    Triggers a genuine native window.confirm, worded plainly: "Are you
    sure you want to delete '<name>' and their ALL past deals, invoices,
    bookings and other related items? This cannot be undone." Handled the
    same real way as delete_vehicle's own confirm, a real page.on("dialog",
    ...) listener, never the auto dismiss a plain click would otherwise
    get. confirm=False stops right before accepting the dialog, for a dry
    run; the dialog is always dismissed either way in that case, nothing
    deleted.

    Verifies via the real API afterward that the contact genuinely no
    longer resolves, the same discipline as every other delete in this
    module: a click reporting success is not proof something actually
    happened. Only ever use this on a contact known to be safe to remove
    (a test contact carrying no real deals, invoices or bookings); the
    confirm dialog's own wording is the one honest warning DealerKit
    itself gives that this is not always reversible."""
    page.goto(f"{DEALERKIT_BASE}/contacts/{contact_id}", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)

    del_pos = page.evaluate(
        """() => {
            const els = Array.from(document.querySelectorAll('button, a'));
            for (const el of els) {
                const t = (el.innerText || '').trim();
                if (!t.toUpperCase().includes('DELETE')) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
            }
            return null;
        }"""
    )
    if not del_pos:
        raise RuntimeError(f"DealerKit: the Delete button was not found (contact {contact_id}).")

    accepted = {"flag": False}

    def _on_dialog(d):
        if confirm:
            accepted["flag"] = True
            d.accept()
        else:
            d.dismiss()

    page.on("dialog", _on_dialog)
    try:
        page.mouse.click(del_pos["x"], del_pos["y"])
        page.wait_for_timeout(2000)
    finally:
        page.remove_listener("dialog", _on_dialog)

    if not confirm:
        return False
    if not accepted["flag"]:
        raise RuntimeError(f"DealerKit: the delete confirmation dialog was not seen (contact {contact_id}).")

    resp = page.context.request.get(f"{DEALERKIT_BASE}/api/contacts/{contact_id}", timeout=20000)
    if resp.status < 400:
        raise RuntimeError(f"DealerKit: contact {contact_id} still resolves after delete, it may not have saved.")
    return True

def _download_temp(page, urls):
    """Fetch each url to a local temp file via the browser's own request
    context (page.context.request), not urllib: this Mac's Python has no
    trusted local certificate store (a stock python.org install issue, not
    a DealerKit or imgix problem), so a plain urllib.request.urlopen fails
    every HTTPS fetch with SSLCertVerificationError, silently, the first
    version of this function did not check for it and every image/document
    upload quietly ended up empty. The browser's own network stack has no
    such gap, it is the same one already rendering these exact photos
    everywhere else in the app.

    The local file is named after the URL's OWN basename (v5-172..., not a
    random bidbrain_dk_0_xxxxx one), 2026-08-24, Mark noticed EY69KYV had
    neither V5 nor service history attached despite both having genuinely
    been pushed: DealerKit's own upload widget reads the uploaded file's
    name to auto fill its Documents list "description" (Service History,
    V5 Front, V5 Internal), proven live by comparing HG17NRF's own
    documents, whose v5-...jpg and docs-service-history-...jpg names
    (Motorway's own CDN naming, preserved through here) got a real
    description, against every other pushed car's, all bidbrain_dk_N_xxxxx
    named and all landing as "Other Document Type", DealerKit having
    nothing to guess from. Fixed at the source, not by manually setting a
    description after the fact, so both a fresh push and DealerKit's own
    auto categorisation stay in sync."""
    tmp_dir = tempfile.mkdtemp(prefix="bidbrain_dk_")
    paths = []
    for i, url in enumerate(urls):
        name = os.path.basename(urlparse(url).path) or f"bidbrain-dk-{i}"
        if "." not in name:
            ext = ".jpg"
            for cand in (".jpg", ".jpeg", ".png", ".heic"):
                if cand in url.lower():
                    ext = cand
                    break
            name += ext
        # No index prefix on the name itself: DealerKit's own auto
        # description match needs the exact source filename ("v5-...",
        # "docs-service-history-..."), found live 2026-08-24 when a "0-"
        # prefix added only to dodge same-name collisions was enough on
        # its own to turn a correctly matching upload back into "Other
        # Document Type". A same named collision across one batch is
        # avoided by the file's own directory instead, one url each.
        item_dir = os.path.join(tmp_dir, str(i))
        os.makedirs(item_dir, exist_ok=True)
        path = os.path.join(item_dir, name)
        resp = page.context.request.get(url, timeout=30000)
        if resp.status != 200:
            raise RuntimeError(f"DealerKit: fetching {url} returned HTTP {resp.status}.")
        with open(path, "wb") as f:
            f.write(resp.body())
        paths.append(path)
    return paths


def _cleanup_temp(paths):
    dirs = set()
    for p in paths:
        dirs.add(os.path.dirname(p))
        try:
            os.remove(p)
        except OSError:
            pass
    parents = set()
    for d in dirs:
        parents.add(os.path.dirname(d))
        try:
            os.rmdir(d)
        except OSError:
            pass
    for d in parents:
        try:
            os.rmdir(d)
        except OSError:
            pass


def _server_count(page, vehicle_id, field):
    """The real count of images or documents DealerKit's own API reports for
    this vehicle right now, via the browser's own authenticated session
    (page.context.request), never trusted to a client side "N attached"
    label alone. field is "images" or "documents"."""
    resp = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]={field}",
        timeout=20000)
    if resp.status != 200:
        return None
    try:
        data = resp.json()
    except Exception:
        return None
    return len(data.get(field) or [])


def _is_local_path(item):
    """True when item is a real, already-existing local file rather than
    a url to fetch, checked by actually existing on disk (not just "does
    not start with http") so a stray malformed url is never silently
    treated as a valid local path by accident."""
    return not item.startswith(("http://", "https://")) and os.path.isfile(item)


def _upload_via_edit_tile(page, vehicle_id, tile_text, items, what):
    """Upload a list that can freely mix real urls (downloaded fresh here
    via _download_temp) and already-local file paths, used together in
    one upload so DealerKit's own server side count check still sees the
    real, combined total. Motorway's own photo and document urls are now
    downloaded to real local files AT CAPTURE TIME (2026-08-26, Mark: "the
    motorway v5 images have a '?' ... they dont load", their signed imgix
    urls go dead within about an hour, confirmed live against a 3 day old
    one, so nothing here can safely fetch one a second time), so purchase
    rows read since then carry local paths in the exact same fields a
    Carwow row still carries real urls in; this one function now handles
    either, or a mix, without upload_images/upload_documents needing to
    know or care which."""
    if not items:
        return 0
    urls = [x for x in items if not _is_local_path(x)]
    already_local = [x for x in items if _is_local_path(x)]
    downloaded = _download_temp(page, urls) if urls else []
    try:
        return _upload_local_files_via_edit_tile(page, vehicle_id, tile_text, already_local + downloaded, what)
    finally:
        _cleanup_temp(downloaded)


def _upload_local_files_via_edit_tile(page, vehicle_id, tile_text, local_paths, what):
    """Shared body for the Images and Documents edit panels: both are the
    same upload widget (drag and drop, a file picker, or DealerKit's own
    in-tray), only reached through a different Edit Vehicle menu tile.
    Takes files already sitting on disk (a generated PDF, for example, not
    only something fetched from a url, see upload_local_document).

    Verifies against DealerKit's own API afterward rather than trusting the
    Save Changes click, the same lesson as the Add Expense Team field bug
    (2026-08-23): a save can silently do nothing (no error shown anywhere
    in the UI) while every client side signal, the attached file count, the
    click itself, all look completely normal. Caught live on this exact
    upload: a batch of 25 documents across several cars reported success
    for all of them, but 4 had genuinely saved nothing, only found by
    checking the API afterward. One retry of the Save Changes click if the
    count has not moved; raises loudly if it still has not, rather than
    reporting a false success like the old version did."""
    if not local_paths:
        return 0

    field = "images" if tile_text == "Images" else "documents"

    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)

    before = _server_count(page, vehicle_id, field)

    if not _click_containing(page, "EDIT"):
        raise RuntimeError(f"DealerKit: the Edit button was not found (uploading {what}).")
    page.wait_for_timeout(1500)

    if not _click_visible_text(page, tile_text, exact=True, within=".ui-modal.is-open"):
        raise RuntimeError(f"DealerKit: the {tile_text} tile was not found in the Edit Vehicle menu.")
    page.wait_for_timeout(1500)

    # File inputs are legitimately invisible (styled behind the drop
    # zone), so no bounding-box wait here; scoped to the one open modal
    # (".is-open") rather than a bare page-wide `.first`, since a busier
    # real vehicle page can carry other hidden file inputs (job card,
    # inspections) besides the one for the panel just opened.
    target = page.locator(".ui-modal.is-open input[type=file]").first
    if target.count() == 0:
        target = page.locator("input[type=file]").first
    target.set_input_files(local_paths)

    # Not a fixed wait: DealerKit disables its own Save Changes button
    # while each file is still uploading/processing client side, and for
    # a batch of large photos (a real service history scan can run 3MB+)
    # that genuinely takes well past a few seconds, found live 2026-08-24
    # sitting disabled for a good 9 to 10s on a 7 file, ~16MB batch. A
    # fixed wait short of that lets Save Changes go through mid upload,
    # which silently drops whatever had not finished, exactly the pattern
    # behind a run of "did not actually save" cars in the same batch. The
    # button's own disabled state is the one true signal DealerKit itself
    # already gives for this, so polled instead, generously capped for a
    # very large batch.
    deadline = time.time() + 90
    while time.time() < deadline:
        still_disabled = page.evaluate(
            """() => {
                const b = Array.from(document.querySelectorAll('.ui-modal.is-open button'))
                    .find(el => (el.textContent||'').includes('Save Changes'));
                return b ? b.disabled : false;
            }"""
        )
        if not still_disabled:
            break
        time.sleep(0.5)

    expected = (before or 0) + len(local_paths)
    for attempt in (1, 2):
        if not _click_visible_text(page, "Save Changes", exact=True):
            raise RuntimeError(f"DealerKit: Save Changes was not found after uploading {what}.")
        page.wait_for_timeout(2500)
        after = _server_count(page, vehicle_id, field)
        if after is not None and after >= expected:
            return len(local_paths)
        if attempt == 1:
            # The save did not take. Re-open the same edit panel (the
            # modal usually closes itself after Save Changes even when
            # nothing persisted) so a second click has a real button to
            # hit, rather than clicking into thin air.
            if not _click_containing(page, "EDIT"):
                break
            page.wait_for_timeout(1500)
            if not _click_visible_text(page, tile_text, exact=True):
                break
            page.wait_for_timeout(1500)
    raise RuntimeError(
        f"DealerKit: {what} did not actually save for vehicle {vehicle_id} "
        f"(server still reports {after if after is not None else 'unknown'} "
        f"{field}, expected at least {expected}), even after a retry.")


def upload_local_document(page, vehicle_id, file_path):
    """Attach one file already on disk (a generated PDF, for example) as a
    vehicle Document. Never deletes file_path, that is the caller's own
    file, unlike the url based uploads which clean up their own temp
    downloads."""
    return _upload_local_files_via_edit_tile(page, vehicle_id, "Documents", [file_path], "a document")


def upload_local_documents(page, vehicle_id, file_paths):
    """Attach several files already on disk in one go: Carwow's own service
    record and V5C logbook scans, downloaded and extracted locally by
    carwow.read_purchase_documents, and, since 2026-08-26, Motorway's own
    service history and V5 scans too (motorway.download_purchase_media,
    see purchases_run.py); push_purchase still routes those through the
    ordinary upload_documents call, which now detects a local path just
    as well as a url (see _upload_via_edit_tile), this dedicated entry
    point is for a caller that already knows every item is local, such as
    the Delivery Report PDF. Never deletes the files, they are the
    caller's own."""
    return _upload_local_files_via_edit_tile(page, vehicle_id, "Documents", list(file_paths or []), "documents")


def upload_images(page, vehicle_id, photo_urls, max_images=4):
    return _upload_via_edit_tile(page, vehicle_id, "Images", (photo_urls or [])[:max_images], "images")


def upload_documents(page, vehicle_id, document_urls):
    return _upload_via_edit_tile(page, vehicle_id, "Documents", document_urls or [], "documents")


# Everything purchase.dk_send sends in one go (2026-09-05): the original
# items plus the mileage and the buyer's fee. Order is the order things are
# done in inside push_purchase. The due in date is deliberately NOT here
# (Steven 2026-09-05): a date on the record makes DealerKit hide its own
# CHECK-IN button and move the car to In Stock by itself when the date
# arrives, and the check in is to be a person's decision, mirrored from
# BidBrain or Dealer OS, never DealerKit's own automation.
# "transport" is deliberately in both SEND_ITEMS and FOLLOWUP_ACTIONS
# (2026-09-07, Steven: "the extra costs ... should go from dealer os into
# dealerkit at the start"). The delivery charge now rides along with the
# car when it is sent whole, instead of only ever trailing behind it as a
# follow up; the follow up stays for a delivery invoice that lands later.
# push_dealerkit_row is what tells the two apart, by whether anything a
# follow up cannot do was asked for.
SEND_ITEMS = ("price", "fee", "transport", "mileage", "photos", "documents", "retail")


LAST_OUTCOMES = {}


def local_documents_for(purchase, exists=os.path.exists):
    """Every document on this Mac's disk that a send attaches to the car:
    Carwow's service records and V5 scans, and, since 2026-09-16, what
    Dealer OS holds for the car (the MotorCheck history check PDF,
    dealer_os_documents). A path no longer on disk is left out rather
    than failing the upload. Pure, tested."""
    paths = list(purchase.get("document_paths") or []) + list(purchase.get("v5_local_paths") or [])
    paths += [p for p in (purchase.get("dealer_os_documents") or []) if isinstance(p, str) and exists(p)]
    return paths


def documents_already_there(have, sending):
    """Whether a repeat send should leave DealerKit's documents alone: it
    already holds at least as many as this send would add, and there is
    something to add at all."""
    return sending > 0 and have >= sending


def _open_edit_tile(page, vehicle_id, tile, attempts=6, pause_ms=1500):
    """Open the vehicle's Edit menu and click one of its tiles (Expenses,
    Images, ...), trying again while the menu draws (2026-09-15, found
    live on HX67UMH: the buyer's fee step raised "the Expenses tile was
    not found" on a record whose expenses were plainly there, the menu
    had not finished drawing 1.5 seconds after Edit). Up to `attempts`
    looks, `pause_ms` apart; Edit is pressed again if the menu is not
    open. Raises with the same wording as before when it never appears."""
    if not _click_containing(page, "EDIT"):
        raise RuntimeError(f"DealerKit: the Edit button was not found (vehicle {vehicle_id}).")
    for attempt in range(attempts):
        page.wait_for_timeout(pause_ms)
        if _click_visible_text(page, tile, exact=True, within=".ui-modal.is-open"):
            return
        try:
            if page.locator(".ui-modal.is-open").count() == 0:
                _click_containing(page, "EDIT")
        except Exception:
            pass
    raise RuntimeError(f"DealerKit: the {tile} tile was not found (vehicle {vehicle_id}).")


def push_purchase(page, purchase, items=None, max_photos=4):
    """Push one purchase (a row shaped like db.list_purchases()'s output)
    into DealerKit: due in record, then images, then service history
    documents. Returns a summary dict. Raises on the first step that fails
    outright; a step with nothing to do (no photos, no service history) is
    just skipped, not an error. Raises DealerkitMissingMileage (never
    caught here) when this would be a genuinely new record and purchase
    has no mileage; no bypass exists, see add_due_in.

    items (2026-08-26, Mark: "choose which data to push", the row's own
    kebab menu) is an optional subset of {"price","photos","documents",
    "retail","due_in_date"} plus, since 2026-09-05, "mileage" and "fee"
    (the two extra things a car sent whole carries, see SEND_ITEMS); None
    (the default) pushes all five, the original all-in-one behaviour,
    now including Retail Price (2026-08-26, "the Retail Est is the
    retail price that should be mapped to DK", reversing the earlier
    "always manual" decision) and Due In / In-stock On (2026-08-26,
    "lets now wire up the due in date in BB to DK"). The due in record
    itself still always gets created first regardless (photos and
    documents both need a real vehicle_id to upload to), only whether
    BidBrain's own purchase price gets typed into it is what "price"
    actually gates."""
    reg = purchase["reg"]
    want = items if items is not None else {"price", "photos", "documents", "retail"}
    # A named item is a person deliberately asking for THAT one thing, so
    # it overwrites; the all in one push still leaves a retail price
    # DealerKit already has alone (Steven 2026-09-04: "if we change it in
    # dealer os it should be able to push that to dealerkit to match").
    asked_for = set(items or ())
    outcomes = {}
    global LAST_OUTCOMES
    LAST_OUTCOMES = outcomes  # what a send managed before it died, for the timeline (2026-09-15)
    result = add_due_in(page, reg, purchase=purchase, type_price="price" in want)
    vehicle_id = open_vehicle_overview(page, reg)

    n_images = 0
    if "photos" in want:
        photos = list(purchase.get("photo_urls") or [])
        # A repeat send must not double the photos (found live 2026-09-05:
        # a send that failed after its photos, then ran again, left 6
        # where 3 belonged). Images cannot be matched to BidBrain's own
        # files by name the way documents are, so a record that already
        # holds at least as many photos as BidBrain has is left alone.
        have = _server_count(page, vehicle_id, "images") if result["already_exists"] else 0
        if photos and have and have >= min(len(photos), max_photos):
            outcomes["photos"] = f"already on DealerKit, {have} photos there"
            n_images = 0
        else:
            n_images = upload_images(page, vehicle_id, photos, max_images=max_photos)
            outcomes["photos"] = (f"{n_images} uploaded" if n_images
                                  else "nothing to send, DealerKit already has these or BidBrain holds none")

    n_docs = 0
    if "documents" in want:
        documents = list(purchase.get("service_history_photos") or []) + list(purchase.get("v5_photos") or [])
        # Carwow's own service record and V5C logbook scans (2026-08-24), local
        # files on disk rather than urls (see purchases_run.py and
        # carwow.read_purchase_documents), pushed the same way the Delivery
        # Report PDF already is, a separate upload call since local files use
        # a different mechanism to url based ones.
        local_docs = local_documents_for(purchase)
        # The photos rule, for documents (2026-09-15, Steven: a send on a car
        # DealerKit already has must add what is missing and never double
        # what is there): a record that already holds at least as many
        # documents as this send would add is left alone. Documents cannot
        # be matched by name to what DealerKit shows, so the count is the
        # only honest test, the same one the photos use.
        have_docs = _server_count(page, vehicle_id, "documents") if result["already_exists"] else 0
        if documents_already_there(have_docs, len(documents) + len(local_docs)):
            outcomes["documents"] = f"already on DealerKit, {have_docs} documents there"
            n_docs = 0
        else:
            n_docs = upload_documents(page, vehicle_id, documents)
            if local_docs:
                n_docs += upload_local_documents(page, vehicle_id, local_docs)
            outcomes["documents"] = (f"{n_docs} uploaded" if n_docs
                                     else "nothing to send, DealerKit already has these or BidBrain holds none")

    # THE ONE HONEST PRICE (2026-08-27): what DealerKit gets is car_price,
    # the canonical figure from db.list_purchases (the winning bid less any
    # chips), never a raw column with its own fallback chain. A chipped car
    # pushes its chipped price; an unchipped one is unchanged.
    bb_price = purchase.get("car_price")
    if bb_price is None:
        bb_price = purchase.get("winning_bid") or purchase.get("price")

    purchase_cost = retail_price = None

    # Nothing about the seller's finance goes to DealerKit (Steven
    # 2026-09-16): the settlement is handled at collection or before.

    # Mileage (2026-09-05, sent whole): DealerKit writes its own guessed
    # figure onto a new record, and Steven's rule is that DealerKit is
    # never allowed to guess the mileage, so BidBrain's real figure goes
    # over it as a mileage log entry. Only ever requested by dk_send (a
    # car with no mileage never gets that far, see _start_run_api).
    mileage_set = False
    if "mileage" in want:
        bb_miles = purchase.get("mileage")
        if not bb_miles:
            outcomes["mileage"] = "nothing to send, BidBrain has no mileage for this car"
        elif result.get("mileage_on_record") == int(bb_miles):
            mileage_set = True
            outcomes["mileage"] = f"entered as {int(bb_miles):,} miles when the car was added"
        else:
            res = add_mileage_log_entry(page, vehicle_id, bb_miles)
            mileage_set = True
            if res["changed"]:
                outcomes["mileage"] = (f"set to {int(bb_miles):,} miles"
                                       + (f", DealerKit had {res['old']:,}" if res.get("old") is not None else ""))
            else:
                outcomes["mileage"] = f"already right on DealerKit, {int(bb_miles):,} miles"

    # The purchase figures, one route whatever state the record is in
    # (2026-09-16, ND66XYJ and FJ67WFR: the Stocklisting box at creation
    # saved the price on neither, the send then said "nothing to send,
    # BidBrain had no confident figure" although it had one, and the
    # buyer's fee was refused with "send the purchase price first". Nothing
    # ever retried, and DealerKit held no purchase figures at all). Now a
    # price saved at creation counts as done; otherwise the price, the
    # buyer's fee and the transport fee go on the supplier's purchase
    # expense together: created when the car has none, corrected when a
    # line differs, left when it already matches. A failure here never
    # kills the rest of the send: it is said per line, in words, and the
    # automatic run retries it (db.dk_figures_missing).
    fee_set = transport_set = False
    bb_fee = purchase.get("effective_fee")
    bb_transport = purchase.get("transport_fee")
    figures = {}
    if "price" in want:
        if bb_price is None:
            outcomes["price"] = "nothing to send, BidBrain has no purchase price for this car"
        elif result["created"] and result["purchase_price_filled"]:
            # We typed it, clicked Save, and confirmed it via DealerKit's
            # own API in add_due_in (2026-09-05).
            purchase_cost = bb_price
            outcomes["price"] = f"set to £{bb_price} when the car was created"
        else:
            figures["chassis"] = bb_price
    if "fee" in want:
        if bb_fee is None:
            outcomes["fee"] = "nothing to send, BidBrain has no buyer's fee for this car"
        else:
            figures["buyers_premium"] = bb_fee
    if "transport" in want:
        if bb_transport is None:
            outcomes["transport"] = "nothing to send, BidBrain has no transport fee for this car"
        else:
            figures["delivery_charge"] = bb_transport
    if figures:
        supplier = _default_supplier(purchase)
        creation_note = ""
        if result["created"] and "chassis" in figures and result.get("price_reason"):
            creation_note = f" (not saved when the car was created: {result['price_reason']})"
        try:
            synced = sync_purchase_expense(page, vehicle_id, supplier, figures)
        except DealerkitSessionExpired:
            raise
        except Exception as e:
            words = " ".join(str(e).split())[:300]
            for key in figures:
                outcomes[FIGURE_OUTCOME_KEY[key]] = (f"not on DealerKit, {words}"
                                                    + (creation_note if key == "chassis" else ""))
        else:
            for key, res in synced.items():
                outcomes[FIGURE_OUTCOME_KEY[key]] = figure_words(res, supplier) + (
                    creation_note if key == "chassis" and res.get("changed") else "")
                landed = figure_landed(res)
                if key == "chassis" and landed:
                    purchase_cost = res["new"]
                elif key == "buyers_premium" and landed:
                    fee_set = True
                elif key == "delivery_charge" and landed:
                    transport_set = True

    # Retail Price, BidBrain's own retail_estimate figure (2026-08-26,
    # "the Retail Est is the retail price that should be mapped to DK").
    # Never guessed: only set when BidBrain actually has a real figure
    # for this car, and never touched at all when DealerKit already has
    # one (see push_dealerkit_checked's own default-ticked logic on the
    # purchases page, an already-priced car does not default to being
    # re priced automatically), matching every other "only overwrite a
    # confirmed figure on a deliberate re tick" rule in this file.
    retail_price_set = False
    if "retail" in want:
        bb_retail = purchase.get("retail_estimate")
        if bb_retail is None:
            outcomes["retail"] = "nothing to send, BidBrain has no retail estimate for this car"
        else:
            existing_retail = retail_price
            if existing_retail is None:
                existing = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}")
                existing_retail = existing.get("retail_price")
            if existing_retail is not None and abs(float(existing_retail) - float(bb_retail)) < 0.01:
                retail_price = existing_retail
                outcomes["retail"] = f"already right on DealerKit, £{existing_retail}"
            elif existing_retail is not None and "retail" not in asked_for:
                outcomes["retail"] = (f"left alone, DealerKit says £{existing_retail} and BidBrain says "
                                      f"£{bb_retail}; tick Retail price on its own to make DealerKit match")
            else:
                retail_price_set = set_retail_price(page, vehicle_id, bb_retail)
                if retail_price_set:
                    retail_price = bb_retail
                    how = " through the API" if retail_price_set == "api" else " on the Pricing panel"
                    outcomes["retail"] = (f"set to £{bb_retail}{how}" if existing_retail is None
                                          else f"changed from £{existing_retail} to £{bb_retail}{how}")
                else:
                    outcomes["retail"] = "could not be set on DealerKit"

    # Due In / In-stock On, BidBrain's own collection_date (2026-08-26,
    # Mark: "lets now wire up the due in date in BB to DK", the same
    # field found live on the Miscellaneous edit panel, see
    # set_delivery_date). Unlike Retail Price this is a plain fact, not a
    # market judgement call, so it is always kept in sync rather than
    # left alone once DealerKit has any value: set_delivery_date itself
    # is the one place that decides whether anything actually needs to
    # change (a no op when already correct, a real write and verify
    # otherwise), it only ever returns without raising once the DealerKit
    # date is confirmed to genuinely match, so a clean return here always
    # means the figure is now correct, freshly written or already there.
    due_in_date_set = False
    if "due_in_date" in want:
        if purchase.get("collection_date") is None:
            outcomes["due_in_date"] = "nothing to send, BidBrain has no due in date for this car"
        else:
            changed = set_delivery_date(page, vehicle_id, purchase["collection_date"])
            due_in_date_set = True
            outcomes["due_in_date"] = (f"set to {purchase['collection_date']}" if changed
                                       else f"already right on DealerKit, {purchase['collection_date']}")

    # What genuinely succeeded, not just what was attempted (2026-08-26,
    # found live on BT16TYV: it had nothing at all to offer for documents,
    # yet used to be marked dk_documents_pushed_at anyway, since the old
    # items_pushed field just echoed back `want`, and callers stamped the
    # database from that rather than a real outcome. That silently
    # inflated the row's own master icon to a false green, "completely
    # wrong ... but the rollover looks like its missing lots of info").
    # This is now the one thing db.mark_dealerkit_pushed should ever be
    # called with.
    items_done = set()
    if "price" in want and purchase_cost is not None:
        items_done.add("price")
    if "photos" in want and n_images > 0:
        items_done.add("photos")
    if "documents" in want and n_docs > 0:
        items_done.add("documents")
    if "retail" in want and retail_price_set:
        items_done.add("retail")
    if "due_in_date" in want and due_in_date_set:
        items_done.add("due_in_date")
    if "mileage" in want and mileage_set:
        items_done.add("mileage")
    if "fee" in want and fee_set:
        items_done.add("fee")
    if "transport" in want and transport_set:
        items_done.add("transport")

    return {
        "reg": reg,
        "vehicle_id": vehicle_id,
        "created": result["created"],
        "already_existed": result["already_exists"],
        "purchase_price_filled": result["purchase_price_filled"],
        "purchase_cost": purchase_cost,
        "retail_price": retail_price,
        "retail_price_set": retail_price_set,
        "due_in_date_set": due_in_date_set,
        "images_uploaded": n_images,
        "documents_uploaded": n_docs,
        "items_pushed": sorted(want),
        "items_done": sorted(items_done),
        # One plain sentence per thing asked for, saying what actually
        # happened rather than only what succeeded (2026-09-04: a click
        # reported "sent" when the figure was merely already right, and
        # a skipped retail price gave a reason that was not true).
        "outcomes": outcomes,
    }


# Category ids on this account's Stock Purchase expense categories, read
# live off the real "Expense Categories" picker (2026-08-19 build, still
# accurate 2026-08-23). Chassis is the only one billed VAT free (N), the
# other three are Standard (S), matching every human and automated entry
# checked across the whole account during the 2026-08-23 audit.
_EXPENSE_CATEGORY_LABEL = {
    "chassis": "Chassis",
    "buyers_premium": "Buyers Premium",
    "delivery_charge": "Delivery Charge",
    "indemnity_fee": "Assurance / Indemnity Fee",
}
_EXPENSE_CATEGORY_VAT_FREE = {"chassis"}
_EXPENSE_CATEGORY_GROUPS = ("Stock Purchase", "Cost of Purchase")


def _add_button_label(text):
    """A DealerKit button's own label with the material icon glyph taken
    off: "add\nEXPENSE" is "expense", "add\nADD" is "add", a bare "add"
    (the icon only + beside a contact picker) is ""."""
    words = str(text or "").split()
    if words and words[0] == "add" and len(words) > 1:
        words = words[1:]
    elif words == ["add"]:
        return ""
    return " ".join(words).lower()


def _choose_add_button(cands):
    """Which of the open modal's buttons with "add" in their text is the
    expense item's own ADD (2026-09-16, seen live on Steven's account: the
    form offers "+ EXPENSE", "+ CREDIT", an icon only "+" beside the
    supplier picker and the item row's own "ADD" at once, and picking by
    size or by the word "expense" pressed the wrong one twice). cands is
    [{text, ui, area, ...}] as the page reports them. A button that starts
    another expense, a credit or a contact is never it; the icon only "+"
    is never it while anything else offers; a label reading "add" or
    naming the item wins; then the smallest. Returns the index into cands,
    None when nothing offers."""
    rows = [(i, c, _add_button_label(c.get("text"))) for i, c in enumerate(cands or []) if c.get("text")]
    if not rows:
        return None
    rows = [r for r in rows if "item" in r[2] or not any(w in r[2] for w in ("expense", "credit", "contact", "invoice"))] or rows
    real = [r for r in rows if r[2]] or rows
    named = [r for r in real if r[2] == "add" or "item" in r[2]] or real
    ui = [r for r in named if r[1].get("ui")] or named
    return min(ui, key=lambda r: float(r[1].get("area") or 0))[0]


def _capture_shot(page, cap_bytes=45000):
    """A small, low quality picture of the topmost open DealerKit window,
    clipped to just that window so it stays small (2026-09-16, Steven:
    "is there any way at all that you will be able to make it so you can
    sign in to dealerkit from here?" — no browser session or password
    lives outside the Mac, but the Mac can send back a real picture of
    what it saw, not just words, every time a step fails or the survey
    runs). Returns base64 JPEG bytes (a plain str) or None. Never raises;
    quietly gives up rather than block or fail whatever called it."""
    try:
        box = page.evaluate(
            """() => {
                const opens = document.querySelectorAll('.ui-modal.is-open');
                const m = opens.length ? opens[opens.length - 1] : null;
                const r = (m || document.body).getBoundingClientRect();
                const vw = window.innerWidth, vh = window.innerHeight;
                const x = Math.max(0, r.x), y = Math.max(0, r.y);
                const w = Math.max(50, Math.min(r.width || vw, vw - x));
                const h = Math.max(50, Math.min(r.height || vh, vh - y));
                return {x, y, width: w, height: h};
            }"""
        )
    except Exception:
        box = None
    for quality in (35, 22, 12):
        try:
            data = page.screenshot(type="jpeg", quality=quality,
                                   clip=box if box else None, timeout=8000)
        except Exception:
            data = None
        if data is None:
            return None
        if len(data) * 4 // 3 <= cap_bytes or quality == 12:
            import base64
            return base64.b64encode(data).decode("ascii")
    return None


def _append_shot(logs_dir, filename, label, page):
    """Base64 JPEG lines appended to a log file, ASCII only so
    diagnostics.tail_lines (a plain text reader) carries it intact: a
    header the puller recognises ("image: <label> jpeg") then the base64
    in 300 character lines, blank line to close it. Never raises."""
    b64 = _capture_shot(page)
    if not b64:
        return
    try:
        os.makedirs(logs_dir, exist_ok=True)
        with open(os.path.join(logs_dir, filename), "a", encoding="utf-8") as f:
            f.write(f"image: {label} jpeg {len(b64)}b64chars\n")
            for i in range(0, len(b64), 300):
                f.write("imgb64:" + b64[i:i + 300] + "\n")
            f.write("imageend:\n")
    except Exception:
        pass


def _screen_words(page, cap=300):
    """The open DealerKit window's own words, for a failure message, and
    the whole window to data/logs/dealerkit_step.log so the next
    diagnostics pull shows what the Mac was looking at (2026-09-16): its
    buttons, its field labels and placeholders, and all of its text, the
    topmost open window first. Never raises."""
    js_top = """() => { const opens = document.querySelectorAll('.ui-modal.is-open'); return opens.length ? opens[opens.length - 1] : document.body; }"""
    def ev(js):
        try:
            return page.evaluate(js) or []
        except Exception:
            return []
    try:
        said = " ".join((page.evaluate("(" + js_top + ")().innerText || ''") or "").split())
    except Exception:
        said = ""
    if not said:
        try:
            said = " ".join((_modal_words(page) or "").split())
        except Exception:
            said = ""
    btns = ev("""() => { const opens = document.querySelectorAll('.ui-modal.is-open'); const m = opens.length ? opens[opens.length - 1] : document.body;
        return Array.from(m.querySelectorAll('button'))
            .filter(b => { const r = b.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
            .map(b => (b.innerText || '').trim().replace(/\s+/g, ' ')).filter(Boolean).slice(0, 40); }""")
    fields = ev("""() => { const opens = document.querySelectorAll('.ui-modal.is-open'); const m = opens.length ? opens[opens.length - 1] : document.body;
        const out = [];
        for (const el of m.querySelectorAll('.ui-select__label-text, .ui-select__display-value, .ui-select__display, .ui-textbox__label-text, label, [placeholder], select')) {
            const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) continue;
            const t = ((el.innerText || '') || el.getAttribute('placeholder') || '').trim().replace(/\s+/g, ' ');
            const tag = el.tagName.toLowerCase() + (el.className && typeof el.className === 'string' ? '.' + el.className.split(' ')[0] : '');
            if (t && !out.includes(tag + ': ' + t)) out.push(tag + ': ' + t);
        }
        return out.slice(0, 60); }""")
    logs = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "logs")
    try:
        os.makedirs(logs, exist_ok=True)
        with open(os.path.join(logs, "dealerkit_step.log"), "a", encoding="utf-8") as f:
            f.write(f"--- {datetime.datetime.now().isoformat(timespec='seconds')} {page.url}\n")
            f.write("buttons: " + " | ".join(btns)[:380] + "\n")
            for i in range(0, len(fields), 6):
                f.write("fields: " + " | ".join(fields[i:i + 6])[:380] + "\n")
            for i in range(0, min(len(said), 6000), 350):
                f.write("screen: " + said[i:i + 350] + "\n")
    except Exception:
        pass
    # A real picture of the screen the failure happened on, not just its
    # words (2026-09-16): every genuine failure now carries one, so the
    # next diagnostics pull can be looked at, not just read.
    _append_shot(logs, "dealerkit_step.log", "failure", page)
    return f"The window says: {said[:cap]}" if said else "The window showed no words."


def _top_modal(page):
    """The topmost open DealerKit window, as a locator."""
    return page.locator(".ui-modal.is-open").last


def _select_shows(page, label):
    """What the ui-select labelled `label` in the topmost open window shows
    now ("Cost of Purchase > Chassis", "Z - Zero"), None when there is no
    such field. Read live on Steven's account 2026-09-16 (the survey)."""
    try:
        return page.evaluate(
            """(label) => {
                const opens = document.querySelectorAll('.ui-modal.is-open');
                const m = opens.length ? opens[opens.length - 1] : document.body;
                for (const sel of m.querySelectorAll('.ui-select')) {
                    const lab = sel.querySelector('.ui-select__label-text');
                    if (!lab || lab.textContent.trim() !== label) continue;
                    const r = sel.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0) continue;
                    const d = sel.querySelector('.ui-select__display-value, .ui-select__display');
                    return d ? d.textContent.trim() : '';
                }
                return null;
            }""",
            label,
        )
    except Exception:
        return None


def _click_select_by_label(page, label):
    """Open the ui-select labelled `label` in the topmost open window by
    clicking low in its box, the way _click_ui_select_field does; False
    when there is no such field."""
    pos = page.evaluate(
        """(label) => {
            const opens = document.querySelectorAll('.ui-modal.is-open');
            const m = opens.length ? opens[opens.length - 1] : document.body;
            for (const sel of m.querySelectorAll('.ui-select')) {
                const lab = sel.querySelector('.ui-select__label-text');
                if (!lab || lab.textContent.trim() !== label) continue;
                const r = sel.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height * 0.75};
            }
            return null;
        }""",
        label,
    )
    if not pos:
        return False
    page.mouse.click(pos["x"], pos["y"])
    return True


def _set_vat_tariff(page, wanted, vehicle_id):
    """Make the item window's VAT Tariff read `wanted` ("N - No VAT" for the
    chassis, "S - Standard" for a fee). Steven's account presets "Z - Zero"
    on a chassis line (the survey, 2026-09-16), so the tariff is always set
    outright, never assumed. Raises with the window's words when the
    tariff cannot be set."""
    shown = _select_shows(page, "VAT Tariff")
    if shown is not None and shown.strip().lower() == wanted.lower():
        return
    opened = _click_select_by_label(page, "VAT Tariff") or (
        shown and _click_visible_text(page, shown, exact=True, timeout=3.0, within=_top_modal(page)))
    if not opened:
        raise RuntimeError(f"DealerKit: the VAT Tariff box was not found (vehicle {vehicle_id}). {_screen_words(page)}")
    page.wait_for_timeout(500)
    try:
        _click_dropdown_option(page, wanted)
    except Exception:
        if not _click_visible_text(page, wanted, exact=True, timeout=3.0):
            raise RuntimeError(f"DealerKit: the VAT option {wanted!r} was not offered (vehicle {vehicle_id}). {_screen_words(page)}")
    page.wait_for_timeout(500)


def _add_expense_items(page, items, vehicle_id):
    """Add one or more line items (items is a dict keyed by
    "chassis"/"buyers_premium"/"delivery_charge"/"indemnity_fee", value the
    real amount) to whichever expense's own "Add Expense Item" flow is
    currently open on the page (an existing supplier's expense being
    edited, or a genuinely new one just created, both use the identical
    widget). Pulled out of add_missing_expense_items 2026-08-24 so
    create_expense_for_vehicle (a genuinely new expense, needed for
    Carwow, which has no live DealerKit integration auto creating one the
    way Motorway does) can reuse the exact same proven item adding
    mechanics rather than duplicate them.

    Rewritten from the real screens of Steven's account (the costs screen
    survey, 2026-09-16, after four presses on ND66XYJ each stopped one
    step further in): the form's own "ADD" (never "+ EXPENSE", "+ CREDIT"
    or the icon only "+") opens a separate "Add Expense Item" window with
    Item Category (already set, "Cost of Purchase > Chassis" on a fresh
    form), Item Description, Qty, Unit Net £'s, VAT Tariff (preset
    "Z - Zero") and its own "Add". The category is opened only when it
    does not already read the line wanted; the "Expense Categories"
    window then takes the group and the line, scoped to that window so
    the same words behind it are never clicked. The VAT tariff is set
    outright every time. Raises loudly, with the window's words, on the
    first step that cannot be found; never guesses a category or field."""
    for key, amount in items.items():
        label = _EXPENSE_CATEGORY_LABEL[key]
        cands = page.evaluate(
            """() => {
                const btns = Array.from(document.querySelectorAll('.ui-modal.is-open button'));
                const out = [];
                for (const b of btns) {
                    const t = (b.innerText || '').trim();
                    if (!t.toLowerCase().includes('add')) continue;
                    const r = b.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) out.push({text: t, ui: b.classList.contains('ui-button'), x: r.x + r.width/2, y: r.y + r.height/2, area: r.width*r.height});
                }
                return out;
            }"""
        ) or []
        pick = _choose_add_button(cands)
        if pick is None:
            raise RuntimeError(f"DealerKit: the item ADD button was not found (vehicle {vehicle_id}). {_screen_words(page)}")
        page.mouse.click(cands[pick]["x"], cands[pick]["y"])
        page.wait_for_timeout(1200)

        shown = _select_shows(page, "Item Category")
        if shown is None:
            # Not the item window seen on Steven's account: the older
            # in row picker, "Pick a category" (Mark's account, 2026-08-24).
            if not _click_visible_text(page, "Pick a category", exact=False, timeout=5.0):
                raise RuntimeError(f"DealerKit: neither an Item Category box nor a category picker was found "
                                   f"(vehicle {vehicle_id}) after pressing {cands[pick]['text']!r}. {_screen_words(page)}")
            need_pick = True
        elif shown.strip().lower().endswith(label.lower()):
            need_pick = False
        else:
            if not _click_select_by_label(page, "Item Category"):
                raise RuntimeError(f"DealerKit: the Item Category box could not be opened (vehicle {vehicle_id}). {_screen_words(page)}")
            need_pick = True
        if need_pick:
            page.wait_for_timeout(900)
            top = _top_modal(page)
            for i, group in enumerate(_EXPENSE_CATEGORY_GROUPS):
                if _click_visible_text(page, group, exact=False, timeout=4.0 if i else 6.0, within=top):
                    page.wait_for_timeout(800)
                    break
            else:
                # No group offered: the categories window's own search box.
                try:
                    page.keyboard.type(label)
                    page.wait_for_timeout(900)
                except Exception:
                    pass
            if not _click_visible_text(page, label, exact=False, timeout=6.0, within=_top_modal(page)):
                raise RuntimeError(f"DealerKit: category {label!r} was not found (vehicle {vehicle_id}). {_screen_words(page)}")
            page.wait_for_timeout(800)
            after = _select_shows(page, "Item Category")
            if after is not None and not after.strip().lower().endswith(label.lower()):
                raise RuntimeError(f"DealerKit: the Item Category reads {after!r}, not {label!r} (vehicle {vehicle_id}). {_screen_words(page)}")

        # Unit Net: the input nearest the label, in the topmost window (not
        # a DOM order xpath: the "Custom VAT total" switch sits earlier in
        # raw DOM order, found live 2026-08-23). Filled through Playwright's
        # own fill and blurred, then read back, the _fill_purchase_price
        # lesson: DealerKit's form state only takes a value on a real blur,
        # and on ND66XYJ (2026-09-16) a chassis typed by keystrokes was lost
        # when its Add did not take, so the next line wrote over it.
        marked = page.evaluate(
            """() => {
                const opens = document.querySelectorAll('.ui-modal.is-open');
                const m = opens.length ? opens[opens.length - 1] : document.body;
                let label = null;
                for (const el of m.querySelectorAll('*')) {
                    if (el.children.length > 2 && (el.innerText||'').length > 20) continue;
                    if ((el.textContent||'').trim().startsWith('Unit Net')) {
                        const r = el.getBoundingClientRect();
                        if (r.width > 0 && r.height > 0) { label = {x: r.x + r.width/2, y: r.y + r.height/2}; break; }
                    }
                }
                if (!label) return false;
                const inputs = Array.from(m.querySelectorAll('input[type=text], input[type=number], input:not([type])'));
                let best = null, bestDist = Infinity;
                for (const inp of inputs) {
                    const r = inp.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0) continue;
                    const dist = Math.hypot(r.x + r.width/2 - label.x, r.y + r.height/2 - label.y);
                    if (dist < bestDist) { bestDist = dist; best = inp; }
                }
                if (!best) return false;
                document.querySelectorAll('[data-bb-unit-net]').forEach(e => e.removeAttribute('data-bb-unit-net'));
                best.setAttribute('data-bb-unit-net', '1');
                return true;
            }"""
        )
        if not marked:
            raise RuntimeError(f"DealerKit: the Unit Net field was not found (vehicle {vehicle_id}). {_screen_words(page)}")
        field = page.locator("[data-bb-unit-net]").first
        field.click()
        field.fill(f"{amount:.2f}")
        field.blur()
        page.wait_for_timeout(300)
        landed = str(field.input_value() or "")
        if f"{amount:.2f}" not in landed and f"{amount:g}" not in landed:
            raise RuntimeError(f"DealerKit: the Unit Net box reads {landed!r} after typing {amount:.2f} "
                               f"(vehicle {vehicle_id}). {_screen_words(page)}")

        _set_vat_tariff(page, "N - No VAT" if key in _EXPENSE_CATEGORY_VAT_FREE else "S - Standard", vehicle_id)

        # The item's own Add, proven: the item window must close. One more
        # try after a blur, then the window's words.
        for attempt in (1, 2):
            if not _click_visible_text(page, "Add", exact=True, timeout=6.0, within=_top_modal(page)):
                raise RuntimeError(f"DealerKit: the item's own Add button was not found (vehicle {vehicle_id}). {_screen_words(page)}")
            page.wait_for_timeout(1200)
            if _select_shows(page, "Item Category") is None:
                break
            if attempt == 2:
                raise RuntimeError(f"DealerKit: the {label} line was not taken by the item window's Add "
                                   f"(vehicle {vehicle_id}). {_screen_words(page)}")
            try:
                field.blur()
            except Exception:
                pass
            page.wait_for_timeout(600)
        # And the form must now list the line.
        try:
            form_text = page.evaluate(
                """() => { const opens = document.querySelectorAll('.ui-modal.is-open'); const m = opens.length ? opens[opens.length - 1] : document.body; return (m.innerText || ''); }"""
            ) or ""
        except Exception:
            form_text = ""
        if form_text and label.lower() not in form_text.lower():
            raise RuntimeError(f"DealerKit: the form does not list a {label} line after Add "
                               f"(vehicle {vehicle_id}). {_screen_words(page)}")


def create_expense_for_vehicle(page, vehicle_id, items, supplier, team=None):
    """Create a genuinely NEW expense on a vehicle that has none yet
    (Carwow, 2026-08-24: unlike Motorway, DealerKit has no live
    integration that auto creates one, so add_due_in's own single typed
    Purchase Price is the only figure a Carwow due in ever gets on its
    own, real reconnaissance against a real vehicle with "No Expenses"
    found the "+ EXPENSE" button opens an "Add Expense" form with its own
    Team, Supplier Ref # and Supplier (a searchable contact picker,
    "CarWow" already exists as a real contact in this account, found live,
    no need to create one) fields above the same Items table and
    "Pick a category" > "Add" mechanics add_missing_expense_items already
    uses to edit an existing expense, reused here via _add_expense_items.
    supplier is matched by a live search (types it, clicks the first
    matching option), never guessed or created fresh; raises if the exact
    contact is not found rather than leaving a purchase order unattributed
    or misattributed. Verifies the categories actually landed via the API
    afterward, the same lesson as _upload_via_edit_tile and
    add_missing_expense_items: a save can silently do nothing."""
    if not items:
        return 0

    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)

    _open_edit_tile(page, vehicle_id, "Expenses")
    page.wait_for_timeout(1500)

    expense_pos = page.evaluate(
        """() => {
            const btns = Array.from(document.querySelectorAll('.ui-modal.is-open button'));
            for (const b of btns) {
                const t = (b.innerText || '').toUpperCase();
                if (t.includes('EXPENSE') && !t.includes('CREDIT')) {
                    const r = b.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if not expense_pos:
        # A vehicle that already has an expense shows a list, not this
        # empty state's own big green "+ EXPENSE" button; this function is
        # only for a genuinely new expense (add_missing_expense_items is
        # the one for a vehicle that already has one), raise rather than
        # guess which existing expense a human would have meant.
        raise RuntimeError(
            f"DealerKit: the + EXPENSE button was not found (vehicle {vehicle_id} may already "
            "have an expense, use add_missing_expense_items instead)."
        )
    page.mouse.click(expense_pos["x"], expense_pos["y"])
    page.wait_for_timeout(1800)

    # Team: a genuinely different ui-select shape to the Funding modal's own
    # (that one is .ui-select__label-text inside .ui-select, this one shows
    # its placeholder directly as .ui-select__display, found live
    # 2026-08-24), so not _click_ui_select_field, a plain smallest visible
    # text match instead, the same technique already proven for Supplier
    # just below. Every existing expense in this account uses "RightDrive
    # Car Finance - Sales".
    team_pos = page.evaluate(
        """() => {
            const els = document.querySelectorAll('div, span');
            for (const el of els) {
                if (el.children.length > 2) continue;
                if ((el.textContent || '').trim() === 'Select team') {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if not team_pos:
        raise RuntimeError(f"DealerKit: the Team field was not found (vehicle {vehicle_id}).")
    page.mouse.click(team_pos["x"], team_pos["y"])
    page.wait_for_timeout(800)
    if not team:
        # Steven's account was never seen from here (2026-09-16), so the
        # team comes from the form itself, see _choose_team, and the
        # choice is printed for the log.
        team, why = _choose_team(_visible_dropdown_options(page), _preferred_team())
        if not team:
            raise RuntimeError(f"DealerKit: no team was offered on the Add Expense form (vehicle {vehicle_id}).")
        print(f"  DealerKit team for the new purchase expense: {team} ({why})")
    # This account has 8 real teams (found live 2026-08-24: Auto Body
    # Specialist, IMO Car Wash, Kirks Works, Rad66 Cars, three RightDrive
    # Car Finance ones including Sales, STC Automotive), rendered as plain
    # .ui-select-option__basic DIVs, not the funding modal's own LIs, so
    # this always explicitly clicks the real one by its exact text rather
    # than trust a single click on the field itself to land on the right
    # one (an early version assumed a lone team would auto resolve; a
    # second attempt selected "Auto Body Specialist - Bodyshop" instead,
    # proving that assumption wrong).
    _click_dropdown_option(page, team)
    page.wait_for_timeout(600)

    # Supplier: a live searchable contact picker, not a plain ui-select
    # (found by its own "Select contact" placeholder text, distinct
    # widget), typed into and the first real matching option clicked.
    sup_pos = page.evaluate(
        """() => {
            const els = document.querySelectorAll('div, span');
            for (const el of els) {
                if (el.children.length > 2) continue;
                if ((el.textContent || '').trim() === 'Select contact') {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if not sup_pos:
        raise RuntimeError(f"DealerKit: the Supplier field was not found (vehicle {vehicle_id}).")
    page.mouse.click(sup_pos["x"], sup_pos["y"])
    page.wait_for_timeout(800)
    page.keyboard.type(supplier)
    page.wait_for_timeout(1500)
    # The visible option row is "CarWow" (bold) with a small "Supplier"
    # badge directly beneath it inside the SAME clickable container, so
    # that container's own combined textContent reads "CarWowSupplier",
    # never equal to the plain contact name; startsWith rather than an
    # exact match, found live 2026-08-24 (a naive exact match found
    # nothing despite the real option clearly being on screen).
    opt_pos = page.evaluate(
        """(supplier) => {
            // The picker's own options first (2026-09-16); the whole open
            // modal only when it renders them some other way. Never the
            // page behind the modal, whose vehicle list also says the
            // supplier's name.
            let els = Array.from(document.querySelectorAll('.ui-select-option, .ui-select__options li, [role=option]'));
            if (!els.some(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }))
                els = Array.from(document.querySelectorAll('.ui-modal.is-open li, .ui-modal.is-open [role=option], .ui-modal.is-open div'));
            const s = supplier.toLowerCase();
            let best = null, bestLen = Infinity;
            for (const el of els) {
                if (el.children.length > 2) continue;
                const t = (el.textContent || '').trim();
                if (t.toLowerCase().startsWith(s) && t.length < bestLen) {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) { best = {x: r.x + r.width / 2, y: r.y + r.height / 2}; bestLen = t.length; }
                }
            }
            return best;
        }""",
        supplier,
    )
    if not opt_pos:
        raise RuntimeError(
            f"DealerKit: no supplier contact matching {supplier!r} was found "
            f"(vehicle {vehicle_id}); it may need creating by hand first. {_screen_words(page)}"
        )
    page.mouse.click(opt_pos["x"], opt_pos["y"])
    page.wait_for_timeout(800)

    _add_expense_items(page, items, vehicle_id)

    before = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems&with[]=expenseItems.expense"
        "&with[]=expenseItems.expense.contact", timeout=20000).json()
    before_n = len(before.get("expense_items") or [])

    for attempt in (1, 2):
        if not _click_visible_text(page, "Save & Approve", exact=True):
            raise RuntimeError(f"DealerKit: Save & Approve was not found (vehicle {vehicle_id}).")
        page.wait_for_timeout(2500)
        after = page.context.request.get(
            f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems&with[]=expenseItems.expense"
            "&with[]=expenseItems.expense.contact", timeout=20000).json()
        after_items = after.get("expense_items") or []
        if len(after_items) > before_n:
            return len(items)
        if attempt == 1:
            continue
    raise RuntimeError(
        f"DealerKit: the new expense did not actually save for vehicle {vehicle_id} "
        f"({len(after_items)} items after, expected more than {before_n}).")


FIGURE_OUTCOME_KEY = {"chassis": "price", "buyers_premium": "fee", "delivery_charge": "transport"}
FIGURE_WORDS = {"chassis": "purchase price", "buyers_premium": "buyer's fee", "delivery_charge": "transport"}


def _same_contact(a, b):
    """Whether two supplier names are the same DealerKit contact: case and
    spacing do not count (2026-09-16, "Carwow" on DealerKit, "CarWow" in
    the code, "Car Wow" typed by a person)."""
    norm = lambda x: re.sub(r"[^a-z0-9]", "", (x or "").lower())
    return bool(norm(a)) and norm(a) == norm(b)


def _choose_team(options, preferred=None):
    """Which DealerKit team a new purchase expense is filed under, from
    the teams the Add Expense form offers (2026-09-16): the dealer's own
    dealerkit_team setting when it names one of them, else the only team,
    else the one team with Sales in its name, else the first. Returns
    (team, why); (None, why) when the form offered nothing."""
    opts = [str(o).strip() for o in (options or ()) if o and str(o).strip()]
    if not opts:
        return None, "no team was offered"
    if preferred:
        for o in opts:
            if o.lower() == str(preferred).strip().lower():
                return o, "the dealerkit_team setting"
    if len(opts) == 1:
        return opts[0], "the only team"
    sales = [o for o in opts if "sales" in o.lower()]
    if len(sales) == 1:
        return sales[0], "the one sales team"
    if sales:
        return sales[0], f"the first of {len(sales)} sales teams"
    return opts[0], f"the first of {len(opts)} teams"


def _preferred_team():
    """The dealer's own dealerkit_team setting, when one is saved."""
    try:
        from bidbrain import db
        return (db.get_settings() or {}).get("dealerkit_team") or None
    except Exception:
        return None


def _visible_dropdown_options(page):
    """The texts of the options an open ui-select dropdown is showing."""
    try:
        return page.evaluate(
            """() => {
                const out = [];
                for (const el of document.querySelectorAll('.ui-select__options li, .ui-select-option, .ui-select-option__basic')) {
                    const r = el.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0) continue;
                    const t = (el.textContent || '').trim();
                    if (t && !out.includes(t)) out.push(t);
                }
                return out;
            }"""
        ) or []
    except Exception:
        return []


def _purchase_cost_holder(page, vehicle_id):
    """(amount, contact name) of the purchase cost DealerKit holds on this
    car under ANY supplier, (None, None) when it holds none. The guard
    against a second purchase expense: a car whose price a person typed
    under a differently named contact is never given another."""
    data = _get_json(
        page,
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=purchaseCost&with[]=expenseItems"
        "&with[]=expenseItems.expense&with[]=expenseItems.expense.contact")
    cost = (data or {}).get("purchase_cost")
    amount = _expense_item_amount(cost)
    if amount is None:
        return None, None
    holder = None
    for it in (data or {}).get("expense_items") or []:
        if it.get("id") == (cost or {}).get("id") or (
                it.get("expense_id") is not None and it.get("expense_id") == (cost or {}).get("expense_id")):
            holder = (((it.get("expense") or {}).get("contact") or {}).get("company_name")) or None
            break
    return amount, holder


def sync_purchase_expense(page, vehicle_id, supplier, items, team=None):
    """Put the purchase figures on the car's DealerKit record, whatever
    state it is in (2026-09-16). items is {"chassis": price,
    "buyers_premium": fee, "delivery_charge": transport}, any subset, None
    values dropped. The supplier's own purchase expense is read first: a
    line already at the figure is left alone, a line that differs is
    corrected (set_expense_items), a missing line is added, and a car with
    no expense from this supplier at all gets one created with every line
    in one go (create_expense_for_vehicle), under the team _choose_team
    picks. Never makes a second purchase expense: when DealerKit already
    holds a purchase cost under some other contact, nothing is written and
    each line says so. Returns {key: {"old", "new", "changed", "created",
    "held": (amount, contact) or None}} for every item asked for; raises
    with a plain sentence when DealerKit refused or a save did not stick."""
    items = {k: float(v) for k, v in (items or {}).items() if v is not None}
    if not items:
        return {}
    contact, before = _read_supplier_items(page, vehicle_id, supplier)
    if contact is not None:
        res = set_expense_items(page, vehicle_id, items, contact)
        for k in res:
            res[k]["created"] = False
            res[k]["held"] = None
        return res
    amount, holder = _purchase_cost_holder(page, vehicle_id)
    if amount is not None:
        return {k: {"old": None, "new": v, "changed": False, "created": False,
                    "held": (amount, holder or "another contact")} for k, v in items.items()}
    create_expense_for_vehicle(page, vehicle_id, items, supplier, team)
    _, after = _read_supplier_items(page, vehicle_id, supplier)
    missing = [k for k, v in items.items() if not _amount_matches(after.get(k), v)]
    if missing:
        raise RuntimeError(
            f"DealerKit: the new purchase expense saved without "
            f"{', '.join(_EXPENSE_CATEGORY_LABEL[k] for k in missing)} (vehicle {vehicle_id}, now {after}).")
    return {k: {"old": None, "new": v, "changed": True, "created": True, "held": None} for k, v in items.items()}


def figure_landed(res):
    """Whether one line from sync_purchase_expense is right on DealerKit
    now: written, already matching, or held under another contact at the
    same figure."""
    held = res.get("held")
    if held:
        return _amount_matches(held[0], res.get("new"))
    return True


def figure_words(res, supplier):
    """One line's outcome from sync_purchase_expense, in words."""
    new = float(res["new"])
    held = res.get("held")
    if held:
        amount, who = held
        if _amount_matches(amount, new):
            return f"already right on DealerKit, £{new:g}, under {who}"
        return (f"left alone, DealerKit holds £{float(amount):g} under {who}, not {supplier}; "
                f"correct it in DealerKit or move it to {supplier}")
    if res.get("created"):
        return f"set to £{new:g} on a new {supplier} purchase expense"
    if not res.get("changed"):
        return f"already right on DealerKit, £{new:g}"
    old = res.get("old")
    return f"changed from £{float(old):g} to £{new:g}" if old is not None else f"set to £{new:g}"


def _open_supplier_expense(page, vehicle_id, supplier):
    """Open the given supplier's own expense inside Edit Vehicle Expenses,
    landing on the open Edit Expense modal. Factored out of
    add_missing_expense_items (2026-08-27) so set_purchase_price can reuse
    the exact same proven open, every hard won detail included:

    The supplier row's pencil is found by locating the smallest visible
    text match then walking UP to the nearest ancestor that itself
    contains a real button, never the nearest button by the text's own
    y centre: for a vehicle with many expenses (found live 2026-08-23 on
    one with 8+), the supplier text is only the row's own top line of
    three, so its tight bounding box centres well above the true row
    centre and a distant unrelated button won by that measure.

    scrollIntoView first, THEN re measure (2026-08-24): a matching row at
    the bottom edge of this scrollable list (found live on HJ17ECW) has a
    real, in DOM pencil whose own bounding box is still effectively
    unclickable, so the position must be measured AGAIN after the scroll
    settles, never reused from before it."""
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)
    _open_edit_tile(page, vehicle_id, "Expenses")
    page.wait_for_timeout(1500)

    page.evaluate(
        """(supplier) => {
            const els = document.querySelectorAll('div, span, li');
            const matches = [];
            for (const el of els) {
                if (el.children.length > 10) continue;
                const t = (el.innerText || '').trim();
                if (t !== supplier) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) matches.push({el, area: r.width * r.height});
            }
            if (matches.length) {
                matches.sort((a, b) => a.area - b.area);
                matches[0].el.scrollIntoView({block: 'center'});
            }
        }""",
        supplier,
    )
    page.wait_for_timeout(400)
    target = page.evaluate(
        """(supplier) => {
            const els = document.querySelectorAll('div, span, li');
            const matches = [];
            for (const el of els) {
                if (el.children.length > 10) continue;
                const t = (el.innerText || '').trim();
                if (t !== supplier) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) matches.push({el, area: r.width * r.height});
            }
            if (!matches.length) return null;
            matches.sort((a, b) => a.area - b.area);
            let node = matches[0].el;
            let btn = null;
            for (let i = 0; i < 6 && node; i++) {
                btn = node.querySelector('button');
                if (btn) break;
                node = node.parentElement;
            }
            if (!btn) return null;
            const r = btn.getBoundingClientRect();
            const best = (r.width > 0 && r.height > 0) ? {x: r.x + r.width / 2, y: r.y + r.height / 2} : null;
            return best;
        }""",
        supplier,
    )
    if not target:
        raise RuntimeError(f"DealerKit: no {supplier} expense was found to edit (vehicle {vehicle_id}).")
    page.mouse.click(target["x"], target["y"])
    page.wait_for_timeout(1500)


def set_purchase_price(page, vehicle_id, price, supplier="Motorway"):
    """Correct the purchase price on a vehicle ALREADY on DealerKit, the
    writer that never existed (found 2026-08-27: add_due_in types the
    price only at creation and every later push returns early at
    already_exists, so a chip agreed after the car was pushed, which is
    when chips are always agreed, had no code path to DealerKit at all,
    and from there none to Xero).

    The mechanism is the real UI route proven live on 2026-08-24 for the
    same kind of correction (WG18EZE's Buyers Premium 299 to 339): the
    purchase price on DealerKit IS the Chassis line of the supplier's own
    expense, so this deletes that item client side via its own kebab and
    re adds it at the new figure in the same unsaved modal session, then
    one Save & Approve. Net item count never drops below one, which
    matters: an expense emptied to zero items silently refuses to save
    (WX16AUE, 2026-08-24). Verified against the API afterward, one retry,
    loud failure, never a reported success that did not stick.

    Steven's call (2026-08-27) is FLAG, never auto push: nothing calls
    this on a schedule, only a person ticking the price box on a row whose
    mismatch the page has already shown them. NOT yet verified against a
    live DealerKit session; the first real use should be watched, the
    same way every other writer in this module was proven."""
    price = float(price)
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(1500)
    before = _get_json(
        page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=purchaseCost")
    old_value = _expense_item_amount((before or {}).get("purchase_cost"))

    _open_supplier_expense(page, vehicle_id, supplier)

    # The Chassis item's own kebab (more_vert), found by the row wrapper
    # walk, the same shape as the supplier pencil above: the label text's
    # own box is not where the kebab sits.
    chassis_label = _EXPENSE_CATEGORY_LABEL["chassis"]
    kebab = page.evaluate(
        """(label) => {
            const els = document.querySelectorAll('div, span, td, li');
            const matches = [];
            for (const el of els) {
                if (el.children.length > 10) continue;
                const t = (el.innerText || '').trim();
                if (!t.includes(label)) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) matches.push({el, area: r.width * r.height});
            }
            if (!matches.length) return null;
            matches.sort((a, b) => a.area - b.area);
            let node = matches[0].el;
            for (let i = 0; i < 8 && node; i++) {
                const btns = Array.from(node.querySelectorAll('button'));
                const k = btns.find(b => (b.innerText || '').includes('more_vert'));
                if (k) {
                    k.scrollIntoView({block: 'center'});
                    const r = k.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0)
                        return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
                node = node.parentElement;
            }
            return null;
        }""",
        chassis_label,
    )
    if not kebab:
        raise RuntimeError(
            f"DealerKit: the Chassis item's own menu was not found (vehicle {vehicle_id}).")
    page.mouse.click(kebab["x"], kebab["y"])
    page.wait_for_timeout(900)
    if not _click_visible_text(page, "Delete", exact=True):
        raise RuntimeError(
            f"DealerKit: Delete was not offered on the Chassis item (vehicle {vehicle_id}).")
    page.wait_for_timeout(900)

    _add_expense_items(page, {"chassis": price}, vehicle_id)

    for attempt in (1, 2):
        if not _click_visible_text(page, "Save & Approve", exact=True):
            raise RuntimeError(f"DealerKit: Save & Approve was not found (vehicle {vehicle_id}).")
        page.wait_for_timeout(2500)
        after = _get_json(
            page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=purchaseCost")
        got = _expense_item_amount((after or {}).get("purchase_cost"))
        if got is not None and abs(got - price) < 0.01:
            return {"old_value": old_value, "new_value": price}
        if attempt == 2:
            raise RuntimeError(
                f"DealerKit: the purchase price did not actually save for vehicle "
                f"{vehicle_id} (still {got!r}, wanted {price}).")


def add_missing_expense_items(page, vehicle_id, missing, *, team, supplier="Motorway"):
    """Add one or more missing line items (missing is a dict keyed by
    "chassis"/"buyers_premium"/"delivery_charge"/"indemnity_fee", value the
    real amount) onto the vehicle's EXISTING expense from the given
    supplier, found via the real live "Motorway" account wide audit
    2026-08-23: every one of the 71 non "Due In" Motorway purchases already
    has its own single Motorway expense record with at least a Chassis
    line, so this always edits an existing expense, it never creates a new
    one (add_due_in / a fresh "+ EXPENSE" is for a genuinely new purchase).
    Verifies the categories actually landed via the API afterward, the same
    lesson as _upload_via_edit_tile: a save can silently do nothing.
    Raises loudly (never guesses) if the supplier's own expense cannot be
    found, or if a save does not stick after one retry."""
    if not missing:
        return 0

    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)

    before = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems&with[]=expenseItems.expense"
        "&with[]=expenseItems.expense.contact", timeout=20000).json()
    before_cats = {it["expense_category_id"] for it in (before.get("expense_items") or [])
                   if it.get("expense") and it["expense"].get("contact")
                   and it["expense"]["contact"].get("company_name") == supplier}

    _open_supplier_expense(page, vehicle_id, supplier)

    _add_expense_items(page, missing, vehicle_id)

    for attempt in (1, 2):
        if not _click_visible_text(page, "Save & Approve", exact=True):
            raise RuntimeError(f"DealerKit: Save & Approve was not found (vehicle {vehicle_id}).")
        page.wait_for_timeout(2500)
        after = page.context.request.get(
            f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems&with[]=expenseItems.expense"
            "&with[]=expenseItems.expense.contact", timeout=20000).json()
        after_cats = {it["expense_category_id"] for it in (after.get("expense_items") or [])
                      if it.get("expense") and it["expense"].get("contact")
                      and it["expense"]["contact"].get("company_name") == supplier}
        if before_cats < after_cats or (after_cats - before_cats):
            return len(missing)
        if attempt == 1:
            break
    raise RuntimeError(
        f"DealerKit: expense items did not actually save for vehicle {vehicle_id} "
        f"(categories still {after_cats}, expected new ones beyond {before_cats}).")


def _read_expense_tax_at(page, vehicle_id, expense_id):
    """The real, current tax_at (an ISO datetime) for one expense record,
    read via DealerKit's own API, never the UI. None if the expense id is
    not found at all."""
    j = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems&with[]=expenseItems.expense",
        timeout=20000).json()
    for it in j.get("expense_items") or []:
        exp = it.get("expense") or {}
        if exp.get("id") == expense_id:
            return exp.get("tax_at")
    return None


def set_expense_tax_date(page, vehicle_id, expense_id, date_iso, supplier="Motorway"):
    """Correct one expense's own Tax Date (DealerKit's internal field name
    is tax_at) to date_iso ("YYYY-MM-DD"), 2026-08-23, Mark, live in this
    exact modal: "i want the collected date to be the tax date as this is
    when the payment was made and so this is used for the push to xero".
    date_iso should be motorway.read_collection_date's own real value, the
    date Motorway's transport collected the car from the seller, never the
    delivery date (a different, wrong field for this purpose, learned live
    the same session). No op, returns False without touching anything, if
    the stored date already matches (compared by calendar date only, tax_at
    itself carries a time component DealerKit set on its own, not worth
    perturbing when the day is already right). Never a raw API write: this
    drives the real Edit Expense modal, the same route as
    add_missing_expense_items, because DealerKit's other date field
    (delivery_on, at the vehicle level) has no UI path to edit at all and a
    direct API write to it was refused by Claude Code's own safety check as
    untested and invasive; this one is neither, since it is a real UI flow,
    not a bypass."""
    current = _read_expense_tax_at(page, vehicle_id, expense_id)
    if current and current[:10] == date_iso:
        return False

    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)

    _open_edit_tile(page, vehicle_id, "Expenses")

    # This overview page genuinely renders TWO .ui-modal.is-open elements
    # at once (confirmed live 2026-08-23, modalCount: 2, presumably an old
    # instance kept around mid transition), so a match's own bounding box
    # alone is not proof a human could actually click it, a hidden
    # duplicate can still report real width/height. document.elementFromPoint
    # at the candidate's own centre is: it returns whatever element a real
    # click at that pixel would actually hit, so requiring the match (or
    # its own descendant, since a button often wraps an icon/span) to BE
    # that element rules out a same-looking ghost sitting underneath.
    # Two back to back runs against the same vehicle, same code, without
    # this check landed in two different real states, one correct, one
    # stuck on the outer "Choose an area to edit" menu.
    target = _evaluate_until(
        page,
        """(supplier) => {
            const els = document.querySelectorAll('div, span, li');
            const matches = [];
            for (const el of els) {
                if (el.children.length > 10) continue;
                const t = (el.innerText || '').trim();
                if (t !== supplier) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) matches.push({el, area: r.width * r.height});
            }
            matches.sort((a, b) => a.area - b.area);
            for (const m of matches) {
                let node = m.el;
                let btn = null;
                for (let i = 0; i < 6 && node; i++) {
                    btn = node.querySelector('button');
                    if (btn) break;
                    node = node.parentElement;
                }
                if (!btn) continue;
                const r = btn.getBoundingClientRect();
                if (r.width === 0 || r.height === 0) continue;
                const cx = r.x + r.width / 2, cy = r.y + r.height / 2;
                const top = document.elementFromPoint(cx, cy);
                if (top && (top === btn || btn.contains(top) || top.contains(btn))) {
                    return {x: cx, y: cy};
                }
            }
            return null;
        }""",
        supplier,
    )
    if not target:
        raise RuntimeError(f"DealerKit: no {supplier} expense was found to edit (vehicle {vehicle_id}).")
    page.mouse.click(target["x"], target["y"])
    page.wait_for_timeout(1000)

    # The Tax Date field's real <input> sits off screen (a hidden control
    # behind the formatted "DD/MM/YYYY" display text DealerKit actually
    # shows, found live 2026-08-23 at a 0,0 bounding rect), and setting its
    # .value directly in JS updates the raw element without the Vue
    # component's own state, proven live not to change the visible label at
    # all. A real mouse click on the visible formatted date text is what
    # actually opens the calendar popup; a JS-only element.click() on that
    # same text was found NOT to open it either (the open behaviour listens
    # for a real pointer event, not a synthetic "click"), so this needs a
    # genuine page.mouse.click, not page.evaluate.
    date_pos = _evaluate_until(
        page,
        """() => {
            // Every open modal, not just the first: the same duplicate
            // modal instance seen opening this one can still be present,
            // and document.querySelector only ever returns the first.
            const modals = document.querySelectorAll('.ui-modal.is-open');
            for (const modal of modals) {
                const els = Array.from(modal.querySelectorAll('*'));
                const candidates = els.filter(e => e.children.length === 0 &&
                    /^\\d{2}\\/\\d{2}\\/\\d{4}$/.test((e.textContent||'').trim()));
                for (const el of candidates) {
                    const r = el.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0) continue;
                    const cx = r.x + r.width/2, cy = r.y + r.height/2;
                    const top = document.elementFromPoint(cx, cy);
                    if (top && (top === el || el.contains(top) || top.contains(el))) {
                        return {x: cx, y: cy};
                    }
                }
            }
            return null;
        }"""
    )
    if not date_pos:
        raise RuntimeError(f"DealerKit: the Tax Date field was not found (vehicle {vehicle_id}).")
    page.mouse.click(date_pos["x"], date_pos["y"])
    page.wait_for_timeout(800)

    year, month, day = date_iso.split("-")
    import calendar as _cal
    target_month_name = _cal.month_name[int(month)]
    target_header = f"{target_month_name} {year}"

    # Click the header's own "next"/"previous" chevron (found live as the
    # only two small buttons flanking the "Month YYYY" text) until the
    # displayed month matches, capped well beyond any realistic gap between
    # a purchase's booking and its own collection date.
    for _ in range(36):
        shown = page.evaluate(
            """() => {
                const el = Array.from(document.querySelectorAll('*')).find(e =>
                    e.children.length === 0 &&
                    /^(January|February|March|April|May|June|July|August|September|October|November|December) \\d{4}$/.test((e.textContent||'').trim()));
                return el ? el.textContent.trim() : null;
            }"""
        )
        if shown is None:
            raise RuntimeError(f"DealerKit: the date picker's month header was not found (vehicle {vehicle_id}).")
        if shown == target_header:
            break
        shown_month, shown_year = shown.rsplit(" ", 1)
        shown_month_num = list(_cal.month_name).index(shown_month)
        forward = (int(year) - int(shown_year)) * 12 + (int(month) - shown_month_num) > 0
        # Not scoped to .ui-modal.is-open: the calendar popup (header,
        # chevrons, day grid) is a portal appended straight to <body>, not
        # nested inside the modal's own DOM at all, found live 2026-08-23
        # (the modal-scoped search only ever found the modal's own unrelated
        # Close button). Scoped instead to small buttons sitting on the
        # header's own row (same y, within the header's own height either
        # side), which is exactly the two chevrons and nothing else on the
        # page.
        nav_target = page.evaluate(
            """(forward) => {
                const headerEl = Array.from(document.querySelectorAll('*')).find(e =>
                    e.children.length === 0 &&
                    /^(January|February|March|April|May|June|July|August|September|October|November|December) \\d{4}$/.test((e.textContent||'').trim()));
                if (!headerEl) return null;
                const hr = headerEl.getBoundingClientRect();
                const cy = hr.y + hr.height / 2;
                const btns = Array.from(document.querySelectorAll('button')).filter(b => {
                    const r = b.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0 || r.width > 40 || r.height > 40) return false;
                    return Math.abs((r.y + r.height / 2) - cy) < hr.height;
                });
                if (btns.length < 2) return null;
                btns.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
                const b = forward ? btns[btns.length - 1] : btns[0];
                const r = b.getBoundingClientRect();
                return {x: r.x + r.width/2, y: r.y + r.height/2};
            }""",
            forward,
        )
        if not nav_target:
            raise RuntimeError(f"DealerKit: the month navigation arrows were not found (vehicle {vehicle_id}).")
        page.mouse.click(nav_target["x"], nav_target["y"])
        page.wait_for_timeout(400)
    else:
        raise RuntimeError(f"DealerKit: could not navigate the date picker to {target_header} (vehicle {vehicle_id}).")

    day_num = str(int(day))
    day_pos = page.evaluate(
        """(day) => {
            const btns = Array.from(document.querySelectorAll('button.ui-calendar-week__date'));
            const el = btns.find(b => b.textContent.trim() === day && !b.className.includes('is-in-other-month'));
            if (!el) return null;
            const r = el.getBoundingClientRect();
            return {x: r.x + r.width/2, y: r.y + r.height/2};
        }""",
        day_num,
    )
    if not day_pos:
        raise RuntimeError(f"DealerKit: day {day_num} was not found in the date picker (vehicle {vehicle_id}).")
    page.mouse.click(day_pos["x"], day_pos["y"])
    page.wait_for_timeout(500)

    for attempt in (1, 2):
        if not _click_visible_text(page, "Save & Approve", exact=True):
            raise RuntimeError(f"DealerKit: Save & Approve was not found (vehicle {vehicle_id}).")
        page.wait_for_timeout(2500)
        after = _read_expense_tax_at(page, vehicle_id, expense_id)
        if after and after[:10] == date_iso:
            return True
        if attempt == 1:
            break
    raise RuntimeError(
        f"DealerKit: Tax Date did not actually save for vehicle {vehicle_id} "
        f"(still {after!r}, expected {date_iso!r}), even after a retry.")


def _pick_calendar_date(page, date_iso, what):
    """Navigates and clicks a day on the SAME calendar popup component
    (button.ui-calendar-week__date, a "Month YYYY" header, confirmed live
    2026-08-24 to be shared between the Edit Expense modal's Tax Date field
    and the Add to Funding modal's own two date fields) assuming a click
    has already opened it. Same month navigation technique as
    set_expense_tax_date."""
    year, month, day = date_iso.split("-")
    import calendar as _cal
    target_header = f"{_cal.month_name[int(month)]} {year}"

    for _ in range(36):
        shown = page.evaluate(
            """() => {
                const el = Array.from(document.querySelectorAll('*')).find(e =>
                    e.children.length === 0 &&
                    /^(January|February|March|April|May|June|July|August|September|October|November|December) \\d{4}$/.test((e.textContent||'').trim()));
                return el ? el.textContent.trim() : null;
            }"""
        )
        if shown is None:
            raise RuntimeError(f"DealerKit: the {what} date picker's month header was not found.")
        if shown == target_header:
            break
        shown_month, shown_year = shown.rsplit(" ", 1)
        shown_month_num = list(_cal.month_name).index(shown_month)
        forward = (int(year) - int(shown_year)) * 12 + (int(month) - shown_month_num) > 0
        nav_target = page.evaluate(
            """(forward) => {
                const headerEl = Array.from(document.querySelectorAll('*')).find(e =>
                    e.children.length === 0 &&
                    /^(January|February|March|April|May|June|July|August|September|October|November|December) \\d{4}$/.test((e.textContent||'').trim()));
                if (!headerEl) return null;
                const hr = headerEl.getBoundingClientRect();
                const cy = hr.y + hr.height / 2;
                const btns = Array.from(document.querySelectorAll('button')).filter(b => {
                    const r = b.getBoundingClientRect();
                    if (r.width === 0 || r.height === 0 || r.width > 40 || r.height > 40) return false;
                    return Math.abs((r.y + r.height / 2) - cy) < hr.height;
                });
                if (btns.length < 2) return null;
                btns.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x);
                const b = forward ? btns[btns.length - 1] : btns[0];
                const r = b.getBoundingClientRect();
                return {x: r.x + r.width/2, y: r.y + r.height/2};
            }""",
            forward,
        )
        if not nav_target:
            raise RuntimeError(f"DealerKit: the {what} date picker's month navigation arrows were not found.")
        page.mouse.click(nav_target["x"], nav_target["y"])
        page.wait_for_timeout(400)
    else:
        raise RuntimeError(f"DealerKit: could not navigate the {what} date picker to {target_header}.")

    day_num = str(int(day))
    day_pos = page.evaluate(
        """(day) => {
            const btns = Array.from(document.querySelectorAll('button.ui-calendar-week__date'));
            const el = btns.find(b => b.textContent.trim() === day && !b.className.includes('is-in-other-month'));
            if (!el) return null;
            const r = el.getBoundingClientRect();
            return {x: r.x + r.width/2, y: r.y + r.height/2};
        }""",
        day_num,
    )
    if not day_pos:
        raise RuntimeError(f"DealerKit: day {day_num} was not found in the {what} date picker.")
    page.mouse.click(day_pos["x"], day_pos["y"])
    page.wait_for_timeout(500)


def _click_ui_select_field(page, label_text, timeout=10.0):
    """Clicks a labelled field on the Add/Edit to Funding modal by its own
    stable structure: a .ui-select__label-text (Vehicle, Finance House,
    Unit Type) or .ui-datepicker__label-text (Plan Start Date, Funding
    Expiry Date, a genuinely different component, found live 2026-08-24)
    holding the field's own name, inside a .ui-select or .ui-datepicker
    container that also holds the current value or placeholder just below
    it. Far more reliable than matching the placeholder text directly: the
    Vehicle placeholder ("Search by VRM, VIN, Make or Model") wraps across
    a real newline in the underlying markup, so a plain .trim() equality
    check against it never matches, and none of these fields are a real
    <input> at all (real <input> elements on this modal are almost all
    zero sized decoys), so a placeholder attribute lookup finds nothing
    either. Every match is checked, not just the first: this page renders
    more than one label reading the same text at once (two "Vehicle"
    labels found live, the first inside a zero sized hidden duplicate),
    DOM order alone cannot be trusted to put the real one first. Clicked
    low in the container (75% down), landing on the value/placeholder row
    rather than the label row above it."""
    pos = _evaluate_until(
        page,
        """(label) => {
            // Not the FIRST match: this page carries multiple .ui-select
            // fields sharing the same label text at once (found live
            // 2026-08-24, exactly two "Vehicle" labels, the first inside a
            // zero sized hidden duplicate container), so every match is
            // checked for a real, visible container rather than trusting
            // whichever one DOM order puts first.
            const labelEls = Array.from(document.querySelectorAll(
                '.ui-select__label-text, .ui-datepicker__label-text'));
            const matches = labelEls.filter(e => e.textContent.trim() === label);
            for (const match of matches) {
                const container = match.closest('.ui-select, .ui-datepicker');
                if (!container) continue;
                const r = container.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) {
                    return {x: r.x + r.width / 2, y: r.y + r.height * 0.75};
                }
            }
            return null;
        }""",
        label_text,
        timeout=timeout,
    )
    if not pos:
        raise RuntimeError(f"DealerKit: the {label_text} field was not found.")
    page.mouse.click(pos["x"], pos["y"])
    page.wait_for_timeout(500)


def _click_dropdown_option(page, option_text):
    """Clicks an option inside an OPEN ui-select dropdown (ul.ui-select__options,
    li.ui-select-option), scoped to that list specifically. Found live
    2026-08-24, the reason Finance House silently never got set: the real
    Finance House value ("LE Capital") ALSO appears, genuinely visible,
    12 times over in the funding grid sitting behind the modal (every
    other row already funded through LE Capital), so a page wide "find
    the smallest visible match" search reliably picked one of those grid
    cells instead of the real dropdown option, no error, just the wrong
    click landing on inert table text."""
    # Scrolled into view first, then measured: the Finance House list runs
    # to 22 real lenders, LE Capital sitting far enough down (y up to
    # 1216 on a page whose real viewport tops out well short of that,
    # found live 2026-08-24) that a raw coordinate click landed below the
    # visible window, hitting nothing (or the modal's own backdrop,
    # silently closing the whole modal) rather than the option itself.
    found = _evaluate_until(
        page,
        """(text) => {
            const opts = Array.from(document.querySelectorAll(
                '.ui-select__options li, .ui-select-option, .ui-select-option__basic'));
            const match = opts.find(el => el.textContent.trim() === text);
            if (!match) return false;
            match.scrollIntoView({block: 'center'});
            return true;
        }""",
        option_text,
    )
    if not found:
        raise RuntimeError(f"DealerKit: the dropdown option {option_text!r} was not found.")
    page.wait_for_timeout(300)
    pos = page.evaluate(
        """(text) => {
            const opts = Array.from(document.querySelectorAll(
                '.ui-select__options li, .ui-select-option, .ui-select-option__basic'));
            const match = opts.find(el => el.textContent.trim() === text);
            const r = match.getBoundingClientRect();
            return (r.width > 0 && r.height > 0) ? {x: r.x + r.width / 2, y: r.y + r.height / 2} : null;
        }""",
        option_text,
    )
    if not pos:
        raise RuntimeError(f"DealerKit: the dropdown option {option_text!r} was not visible after scrolling.")
    page.mouse.click(pos["x"], pos["y"])
    page.wait_for_timeout(500)


def add_funding_record(page, reg, funded_value, plan_starts_at, funding_expires_at,
                        finance_house="LE Capital", unit_type="Used"):
    """Adds a new funding record to DealerKit's own Finance > Funding
    section (rightdrive.app.dealerkit.uk/finance/funding), via the real
    "Add to Funding" modal, proven live 2026-08-24 (Mark: "LE captial is
    the source of truth ... i need DK funding to match the LE portal").
    plan_starts_at and funding_expires_at are "YYYY-MM-DD" strings.
    Deliberately never lets the modal's own auto calculated Funding Expiry
    Date stand: setting Plan Start Date alone was found live to fill in a
    default about 8 months out, not the 4 month term every real funding
    record on this account actually uses, so the expiry is always set
    explicitly rather than trusted. Verifies via DealerKit's own API
    afterward (GET /api/stocklist/{id}?with[]=fundingRecord), the same
    lesson as every other write in this module: a save can look right
    client side and still not have taken.
    """
    page.goto(f"{DEALERKIT_BASE}/finance/funding", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2000)

    # Not _click_containing: found live 2026-08-24 to sometimes report a
    # successful click (a real, smallest-visible match) without the modal
    # actually opening, most likely a decoy element sharing the same text
    # elsewhere on this busy grid page. The real button is the page's own
    # top right "+ Fund Vehicle" pill, a real <a title="Add Vehicle">, a
    # stable attribute unlike its own visible text (also confirmed with
    # this exact page); verified open by polling for the modal's own
    # heading rather than trusting the click alone.
    fund_btn = page.evaluate(
        """() => {
            const btn = document.querySelector('a[title="Add Vehicle"]');
            if (!btn) return null;
            const r = btn.getBoundingClientRect();
            return (r.width > 0 && r.height > 0) ? {x: r.x + r.width / 2, y: r.y + r.height / 2} : null;
        }"""
    )
    if not fund_btn:
        raise RuntimeError("DealerKit: the Fund Vehicle button was not found.")
    page.mouse.click(fund_btn["x"], fund_btn["y"])

    # Not a blind wait after just the modal's own heading appears: found
    # live 2026-08-24 that the heading can render before this busy grid
    # page's own form fields have actually mounted, so waiting on the
    # heading alone and then a fixed pause was still intermittently too
    # short. Polling for the real Vehicle field itself (via
    # _click_ui_select_field's own retry, given a generous timeout here)
    # waits for the thing actually needed rather than guessing how long
    # that takes.
    _click_ui_select_field(page, "Vehicle", timeout=15)
    page.keyboard.type(reg)
    page.wait_for_timeout(1500)
    if not _click_containing(page, reg.replace(" ", "")[:4], timeout=6):
        raise RuntimeError(f"DealerKit: no vehicle search result matched {reg}.")
    page.wait_for_timeout(800)

    _click_ui_select_field(page, "Finance House")
    _click_dropdown_option(page, finance_house)

    _click_ui_select_field(page, "Plan Start Date")
    _pick_calendar_date(page, plan_starts_at, "Plan Start")

    _click_ui_select_field(page, "Funding Expiry Date")
    _pick_calendar_date(page, funding_expires_at, "Funding Expiry")

    amount_input = page.locator('input[placeholder="e.g. 13200"]').first
    amount_input.click()
    amount_input.fill(f"{funded_value:.2f}")
    page.wait_for_timeout(500)

    # Not _click_visible_text: this button's own text is the icon glyph
    # and label sharing one text node ("add \n Add"), the same pattern
    # already documented for the Edit button elsewhere in this module, so
    # no element's text is ever exactly "Add". The primary submit button
    # class is a stable, unambiguous target instead.
    add_pos = page.evaluate(
        """() => {
            const btns = Array.from(document.querySelectorAll('.ui-button--type-primary'));
            for (const btn of btns) {
                const r = btn.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
            }
            return null;
        }"""
    )
    if not add_pos:
        raise RuntimeError(f"DealerKit: the modal's own Add button was not found (reg {reg}).")
    page.mouse.click(add_pos["x"], add_pos["y"])
    page.wait_for_timeout(2500)

    vehicle_id = _find_vehicle_id_by_reg(page, reg)
    if not vehicle_id:
        raise RuntimeError(f"DealerKit: could not find {reg}'s own vehicle id to verify the funding save.")
    record = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=fundingRecord", timeout=20000).json().get("funding_record")
    if not record or abs((record.get("funded_value") or 0) - funded_value) > 0.01:
        raise RuntimeError(
            f"DealerKit: funding record for {reg} did not actually save "
            f"(got {record!r}, expected funded_value {funded_value}).")
    return record


def _open_funding_row_kebab(page, reg):
    """Clicks the 3 dot menu at the right of a car's own row on the real
    Finance > Funding page (rightdrive.app.dealerkit.uk/finance/funding),
    found by walking up from the row's own reg cell rather than a fixed
    column offset, since the grid is horizontally scrollable and the
    kebab's own screen x position is not fixed."""
    kebab = _evaluate_until(
        page,
        """(reg) => {
            const cell = Array.from(document.querySelectorAll('*')).find(e =>
                e.children.length === 0 && (e.textContent||'').replace(/\\s+/g, '') === reg.replace(/\\s+/g, ''));
            if (!cell) return null;
            let node = cell, row = null;
            for (let i = 0; i < 8 && node; i++) {
                if (node.querySelector && node.querySelector('[class*=more_vert], button')) { row = node; }
                node = node.parentElement;
            }
            if (!row) return null;
            const btn = Array.from(row.querySelectorAll('button')).find(b => (b.textContent||'').includes('more_vert'))
                || row.querySelector('button');
            if (!btn) return null;
            const r = btn.getBoundingClientRect();
            return (r.width > 0) ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
        }""",
        reg,
    )
    if not kebab:
        raise RuntimeError(f"DealerKit: the row menu for {reg} was not found on the Funding page.")
    page.mouse.click(kebab["x"], kebab["y"])
    page.wait_for_timeout(500)


def settle_funding_record(page, reg, settled_on, confirm=True):
    """Marks an existing DealerKit funding record as settled (the loan
    has genuinely been repaid), via the real Finance > Funding row's own
    kebab menu, proven live 2026-08-24 with Mark walking the flow through
    by hand first since there was no car both live in DealerKit and
    genuinely settled on LE Capital's own side to test against safely:
    kebab > Settle > a real "Are you sure you want to settle 1 funding
    records?" confirmation > a Settlement Date modal (defaults to today,
    a real editable date field, same calendar component as the Add to
    Funding modal) > a SETTLE button that finalises it. settled_on is a
    "YYYY-MM-DD" string, the real date LE Capital's own Stock History
    shows for the loan, not just today's date, so a loan settled days ago
    still gets recorded with the date it actually happened.

    confirm=False stops right after the Settlement Date is set, before
    the final SETTLE click, for safely proving the mechanism reaches the
    right point without actually finalising anything, used to verify
    this function against a real still-active record without incorrectly
    marking it settled.

    DealerKit's own kebab menu here has no Edit option at all, only
    Settle and Delete, confirmed live: correcting a wrong funded value on
    an existing record has to go through the caller deleting it and
    add_funding_record adding it back, there is no in place edit."""
    _open_funding_row_kebab(page, reg)

    # The "Are you sure you want to settle 1 funding records?" confirmation
    # is a genuine native browser confirm() dialog, not part of the page's
    # own DOM, confirmed live 2026-08-24 by registering a real dialog
    # handler and reading its exact message. Playwright auto DISMISSES any
    # native dialog it is not told about, silently, before a screenshot or
    # a DOM text search would ever see it, which is why every earlier
    # attempt at finding an "OK"/"Yes" button in the page itself found
    # nothing and nothing visibly happened. A plain page.on listener, not
    # expect_event's context manager: expect_event corrupted Playwright's
    # own sync event loop when _click_visible_text's internal blocking
    # time.sleep polling ran inside it ("this event loop is already
    # running"), found live; a simple listener registered just before the
    # click and removed straight after has none of that reentrancy risk.
    seen = {}

    def _on_dialog(dialog):
        seen["dialog"] = dialog
        if "settle" in dialog.message.lower():
            dialog.accept()
        else:
            dialog.dismiss()

    page.on("dialog", _on_dialog)
    try:
        if not _click_visible_text(page, "Settle", exact=True):
            raise RuntimeError(f"DealerKit: no Settle option was found for {reg}'s funding row.")
        deadline = time.time() + 10
        while "dialog" not in seen and time.time() < deadline:
            page.wait_for_timeout(200)
    finally:
        page.remove_listener("dialog", _on_dialog)

    dialog = seen.get("dialog")
    if not dialog:
        raise RuntimeError(f"DealerKit: no confirmation dialog appeared after clicking Settle for {reg}.")
    if "settle" not in dialog.message.lower():
        raise RuntimeError(f"DealerKit: unexpected confirmation dialog for {reg}: {dialog.message!r}")
    page.wait_for_timeout(800)

    # Not _click_ui_select_field: this modal's own "Settlement Date" text
    # is its title (.ui-modal__header-text), not a field label, found
    # live 2026-08-24, so there is no matching .ui-datepicker__label-text
    # to search for. Only one real date field exists on this modal at
    # all, so no label based disambiguation is needed, just the one
    # visible .ui-datepicker__display-value (again checking every match
    # for a real, non zero sized one, the same hidden duplicate pattern
    # already seen elsewhere on this page).
    date_pos = _evaluate_until(
        page,
        """() => {
            const els = document.querySelectorAll('.ui-datepicker__display-value');
            for (const el of els) {
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
            }
            return null;
        }"""
    )
    if not date_pos:
        raise RuntimeError(f"DealerKit: the Settlement Date field was not found for {reg}.")
    page.mouse.click(date_pos["x"], date_pos["y"])
    page.wait_for_timeout(500)
    _pick_calendar_date(page, settled_on, "Settlement")

    if not confirm:
        return None

    add_pos = page.evaluate(
        """() => {
            const btns = Array.from(document.querySelectorAll('button'));
            for (const btn of btns) {
                if ((btn.textContent||'').trim().toUpperCase() !== 'SETTLE') continue;
                const r = btn.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
            }
            return null;
        }"""
    )
    if not add_pos:
        raise RuntimeError(f"DealerKit: the Settlement Date modal's own SETTLE button was not found for {reg}.")
    page.mouse.click(add_pos["x"], add_pos["y"])
    page.wait_for_timeout(2500)

    vehicle_id = _find_vehicle_id_by_reg(page, reg)
    record = page.context.request.get(
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=fundingRecord", timeout=20000).json().get("funding_record") \
        if vehicle_id else None
    if not record or not record.get("funding_settled_at"):
        raise RuntimeError(f"DealerKit: settling {reg} did not actually save (got {record!r}).")
    return record


def read_dk_funding_list(page):
    """Every vehicle DealerKit's own Finance > Funding page currently
    lists, read via its real API (found live 2026-08-24 while building the
    LE Capital sync: GET /api/funding?with[]=fundingRecord, the same
    request the page itself makes on load), not the page's own rendered
    grid, since the grid needs scrolling to reveal more than a screenful.
    Each vehicle's own registration lives at vehicle.registration.vrm, a
    real stable field, more reliable than parsing it back out of
    search_description (which happens to start with the reg but is not
    guaranteed to). Returns a list of dicts: reg, funding_status,
    funded_value, plan_starts_at ("YYYY-MM-DD" or None), funding_settled_at
    ("YYYY-MM-DD" or None). A vehicle with no funding_record at all (should
    not happen, this list is filtered server side to funded vehicles, but
    never trusted blindly) is skipped."""
    out = []
    offset, limit = 0, 200
    while True:
        resp = page.context.request.get(
            f"{DEALERKIT_BASE}/api/funding?with[]=fundingRecord&sort_order=created_at+DESC"
            f"&offset={offset}&limit={limit}&filters={{}}&page={offset // limit + 1}&per_page={limit}",
            timeout=20000,
        )
        data = resp.json()
        rows = data.get("results") or []
        for r in rows:
            fr = r.get("funding_record")
            vrm = ((r.get("vehicle") or {}).get("registration") or {}).get("vrm")
            if not fr or not vrm:
                continue
            out.append({
                "reg": vrm.replace(" ", "").upper(),
                "funding_status": fr.get("funding_status"),
                "funded_value": fr.get("funded_value"),
                "plan_starts_at": (fr.get("plan_starts_at") or "")[:10] or None,
                "funding_settled_at": (fr.get("funding_settled_at") or "")[:10] or None,
            })
        offset += limit
        if offset >= data.get("total", 0):
            break
    return out


def _stocklist_reg_map(page):
    """Every real DealerKit vehicle currently on the stocklist, keyed by
    normalised registration, one full paginated read (2026-08-26, built
    for check_all_dealerkit_records so a bulk check reads the whole
    stocklist once rather than repeating _find_vehicle_id_by_reg's own
    per reg scan for every single car, which its own docstring already
    warns is "not meant for a hot path")."""
    out = {}
    offset = 0
    while True:
        j = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist?limit=25&offset={offset}&with[]=vehicle")
        rows = j.get("results") or []
        if not rows:
            break
        for v in rows:
            vrm = ((v.get("vehicle") or {}).get("registration") or {}).get("vrm") or ""
            if vrm:
                out[vrm.replace(" ", "").upper()] = v["id"]
        offset += 25
        if offset >= j.get("total", 0):
            break
    return out


class DealerkitRecordGone(RuntimeError):
    """One car's own DealerKit record no longer answers (removed there,
    2026-09-07: SD66XWV's /api/stocklist/242 served a page instead of
    JSON while the session itself was fine, and every run stopped on it
    as if logged out). One car's problem, never the session's."""


def record_gone_not_session(page, exc):
    """Whether a DealerkitSessionExpired raised on one car's own record
    is really that record gone: the session is re probed, and a live one
    means the car, not the login, is the problem."""
    return isinstance(exc, DealerkitSessionExpired) and session_alive(page)


class DealerkitSessionExpired(RuntimeError):
    """Raised when a DealerKit API call comes back HTTP 200 but with the
    sign in page's own HTML instead of real JSON, DealerKit's own way of
    saying the saved session has died rather than a clean 401 (found live
    2026-08-26 diagnosing why a real check on KF66GJZ silently produced
    nothing: _find_vehicle_id_by_reg's own bare resp.json() crashed with a
    raw JSONDecodeError, which purchases_run.check_dealerkit never caught,
    so nothing was ever saved and the failure was invisible). Callers
    should surface this message directly rather than a stack trace."""


def session_alive(page):
    """Whether the browser's saved DealerKit session still works for the
    API, not only for the page (2026-09-07: after the 15:30 login job the
    stock list page rendered its search box, so every opener took the
    session as good, while every API call came back as the sign in page
    and the quiet stage check and the daily run both failed). One cheap
    stocklist read; False on the sign in page or any error."""
    return _session_probe(lambda url: _get_json(page, url, timeout=15000))


def _session_probe(get):
    """The two reads every DealerKit pass makes, in order: the stocklist
    (must answer with its results list) and then ONE car's own record
    (/api/stocklist/{id}, must answer with JSON). 2026-09-07: a session
    from the automatic login answered the list and still served the sign
    in page for every car's record, so a list only probe passed and every
    run died on its first car. get(url) returns the parsed JSON or raises."""
    try:
        j = get(f"{DEALERKIT_BASE}/api/stocklist?limit=1&offset=0")
    except Exception:
        return False
    if not (isinstance(j, dict) and isinstance(j.get("results"), list)):
        return False
    rows = j.get("results") or []
    if not rows:
        return True
    try:
        one = get(f"{DEALERKIT_BASE}/api/stocklist/{rows[0].get('id')}")
    except Exception:
        return False
    return isinstance(one, dict)


def _stocklist_answer(fetch):
    """Whether a stocklist read is the real thing: a JSON object carrying
    the "results" list DealerKit's stocklist always has (2026-09-07: a
    signed out API can answer with a tidy JSON message rather than the
    sign in page, which parsed fine and passed the first probe)."""
    try:
        j = fetch()
    except Exception:
        return False
    return isinstance(j, dict) and isinstance(j.get("results"), list)


def _get_json(page, url, timeout=20000):
    """A GET via the browser's own authenticated session, raising
    DealerkitSessionExpired with a clear, actionable message instead of a
    raw JSONDecodeError when the response is not really JSON (the session
    has died and DealerKit quietly served its own sign in page instead,
    still with a 200 status)."""
    resp = page.context.request.get(url, timeout=timeout)
    status = getattr(resp, "status", None)
    short = url.replace(DEALERKIT_BASE, "")
    if status == 404:
        raise DealerkitRecordGone(f"DealerKit has no record at {short} any more (HTTP 404).")
    try:
        return resp.json()
    except Exception:
        raise DealerkitSessionExpired(
            "DealerKit's saved login has expired (the API returned its sign in page "
            f"instead of real data for {short}, HTTP {status}). Log in again: python3 login.py dealerkit manual, "
            "or Settings > Integrations on the cockpit, then try again."
        ) from None


def _expense_item_amount(item):
    """The money on a DealerKit expense item, tried against the plausible
    field names rather than one guessed key, None when none carries a
    number (2026-08-27, needed for the purchase price value comparison:
    the exact key has never been pinned down in a live session, and a
    presence tick was silently passing a WRONG price as green). Safe for
    the Chassis line specifically because it is N No VAT, so net and
    gross cannot disagree whichever of these the API actually uses. The
    first real check against a live record should confirm which key fires
    and this can then be pinned to it."""
    if not item:
        return None
    for key in ("value", "unit_net", "net", "amount", "total"):
        v = item.get(key)
        if isinstance(v, (int, float)):
            return float(v)
        if isinstance(v, str):
            try:
                return float(v)
            except ValueError:
                continue
    return None


def purchase_cost_figure(purchase_cost):
    """The pounds DealerKit holds as the car's purchase price (2026-09-10):
    the amount on the purchase_cost object itself when it carries one, else
    the Chassis line inside its expense items (the purchase price on
    DealerKit IS the Chassis line, see set_purchase_price). None when the
    record has neither, never a guess. Whole pounds."""
    if not isinstance(purchase_cost, dict):
        return None
    amount = _expense_item_amount(purchase_cost)
    if amount is None:
        items = ((purchase_cost.get("expense") or {}).get("items")) or []
        chassis = _EXPENSE_CATEGORY_LABEL["chassis"].lower()
        for it in items:
            if not isinstance(it, dict):
                continue
            desc = str(it.get("description") or it.get("name") or "").lower()
            if chassis in desc:
                amount = _expense_item_amount(it)
                if amount is not None:
                    break
    return None if amount is None else int(round(amount))


def _expense_categories_present(purchase_cost):
    """Which of the itemised expense categories genuinely appear on this
    car's own purchase expense right now (2026-08-26, completing check_
    dealerkit_record's own auction_fee/delivery/indemnity fields now that
    a real logged in session finally showed the actual shape: purchase_
    cost.expense.items, each carrying a plain description). Matched by
    that description TEXT against the exact label strings _EXPENSE_
    CATEGORY_LABEL already uses to ADD these same lines elsewhere in this
    file, never by expense_category_id (only ever observed live on one
    real vehicle, KF66GJZ, not confidently generalised to every id on the
    account), so this reuses the one mapping already audited and proven
    correct rather than a fresh guess."""
    if not purchase_cost:
        return set()
    items = ((purchase_cost.get("expense") or {}).get("items")) or []
    present = set()
    for it in items:
        desc = (it.get("description") or "").lower()
        for key, label in _EXPENSE_CATEGORY_LABEL.items():
            if key != "chassis" and label.lower() in desc:
                present.add(key)
    return present


def _document_types_present(docs):
    """Which of Service History / V5 genuinely appear among this car's own
    DealerKit documents right now (2026-08-26, Mark on AY66WFW: "showing
    missing service and v5, however whe i checked the record in DK both
    are present"). Matched two ways, both real signals, never a guess:
    the document's own "description" field, DealerKit's real per-document
    label, but only ever set when a person has actually picked it from
    DealerKit's own dropdown (confirmed live on HG17NRF: its two hand
    added documents read "Service History"/"V5 Front"/"V5 Internal", every
    BidBrain pushed one alongside them, including literal duplicates of
    the same two files, reads the default "Other Document Type", the
    labelling step flagged unbuilt on 2026-08-24 is still unbuilt); and
    the document's own filename, since BidBrain's own uploads (this file,
    motorway.py, carwow.py) keep the real source name, docs-service-
    history-.../v5-..., for anything pushed since the 2026-08-24 filename
    fix in this file. A document pushed before that fix kept a random
    bidbrain-dk-N-... name and carries neither signal, so it can still
    legitimately be a genuine Service History or V5 scan that this cannot
    identify (confirmed live: AY66WFW's own 2 V5 photos, pushed
    2026-08-23, are exactly this case). This only ever adds a category
    in from a positive match, never rules one out from an absence."""
    present = set()
    for d in docs:
        text = ((d.get("filename") or "") + " " + (d.get("description") or "")).lower()
        if "service" in text:
            present.add("service")
        if "v5" in text or "logbook" in text:
            present.add("v5")
    return present


def check_dealerkit_record(page, reg, vehicle_id=None, alt_regs=()):
    """What is REALLY on this car's DealerKit record right now (2026-08-26,
    Mark: "add a check DK record for whats been added or present on the
    record"), independent of whatever BidBrain's own dk_*_pushed_at flags
    say was attempted: a push can succeed here but the record can still be
    edited or have something deleted in DealerKit afterward, or a step can
    partially fail in a way the push itself did not catch. Pure reads via
    the browser's own authenticated API session (_server_count for images,
    the same primitive _upload_via_edit_tile already trusts over any
    client side "N attached" label, plus the vehicle's own core record for
    its purchase price, retail price, expense line items, documents and
    life cycle stage), no page navigation or clicking needed, so this is
    fast even checking many cars in one pass. vehicle_id can be passed in
    already resolved (see _stocklist_reg_map and vehicle_id_from_map) to
    skip the reg lookup on a bulk check; looked up fresh otherwise, in
    which case alt_regs carries any other plate the car is known by (the
    plate underneath a kept private plate, see find_vehicle_by_plates).
    Returns None when the car has no DealerKit record under ANY of its
    plates yet. Raises DealerkitSessionExpired if the session has died.

    service/v5 can only ever be confirmed True (a real filename or label
    match, see _document_types_present) or, with no documents at all,
    confirmed False; with documents present but no match either way they
    stay None, since an older or foreign document can still genuinely be
    one without this being able to tell."""
    if vehicle_id is None:
        vehicle_id = _find_vehicle_id_by_reg(page, reg, alt_regs)
    if not vehicle_id:
        return None
    images = _server_count(page, vehicle_id, "images")
    data = _get_json(
        page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=documents")
    docs = data.get("documents") or []
    documents = len(docs)
    doc_types = _document_types_present(docs)
    purchase_cost = data.get("purchase_cost")
    retail_price = data.get("retail_price")
    # life_cycle_status 5 is "Due In", confirmed live and referenced
    # repeatedly elsewhere in this project (see add_due_in's own
    # docstring and CLAUDE.md); a car that has moved on to another stage
    # (in stock, sold) still has a real record (vehicle_id is set) but is
    # no longer genuinely "Due In".
    due_in = data.get("life_cycle_status") == 5
    cats = _expense_categories_present(purchase_cost)
    # No expense at all means definitely no itemised line either, a real
    # confirmed False, not an unknown; only Service/V5 above stay None,
    # nothing here needs a "could not check" state of its own.
    has_expense = purchase_cost is not None

    def _doc_state(key):
        if key in doc_types:
            return True
        return False if documents == 0 else None

    return {
        "vehicle_id": vehicle_id,
        "images": images,
        "documents": documents,
        "purchase_cost": purchase_cost,
        # The actual figure where one can be read (2026-08-27), so the page
        # can compare it against BidBrain's own car_price and FLAG a
        # mismatch rather than showing a green presence tick over a wrong
        # number. None when the amount key cannot be read, in which case
        # the page falls back to the old presence-only behaviour.
        "purchase_cost_value": _expense_item_amount(purchase_cost),
        "retail_price": retail_price,
        "due_in": due_in,
        # A real date on delivery_on (2026-08-26, Mark: "lets now wire up
        # the due in date in BB to DK"), presence only, matching every
        # other row here, not a value comparison against BidBrain's own
        # collection_date: set_delivery_date always re-syncs to whatever
        # BidBrain currently believes on a re push, never blocked the way
        # Retail Price is, so a stale DealerKit date self corrects rather
        # than needing its own separate "does not match" state.
        "due_in_date": data.get("delivery_on") is not None,
        # The stage DealerKit holds the car at and its arrival date, read
        # for Dealer OS (2026-09-07, "DealerKit wins on arrival"): Due In,
        # In Stock, Sold, or DealerKit's own label for anything else.
        "life_cycle_status": data.get("life_cycle_status"),
        "stage": stage_label(data),
        "delivery_on": data.get("delivery_on"),
        # The day a Sold car went (2026-09-13), for the sales backfill.
        "disposed_on": data.get("disposed_on"),
        "auction_fee": ("buyers_premium" in cats) if has_expense else False,
        "delivery": ("delivery_charge" in cats) if has_expense else False,
        "indemnity": ("indemnity_fee" in cats) if has_expense else False,
        "service": _doc_state("service"),
        "v5": _doc_state("v5"),
    }


STAGE_LABELS = {5: "Due In", 10: "In Stock", 25: "Sold"}
# The Integrator API (Dealer OS's DealerKit feed, 2026-09-09) carries the
# stage as words only; these are the numbers the rest of this file works in.
STAGE_NUMBERS = {"Due In": 5, "In Stock": 10, "Awaiting Delivery": 20, "Sold": 25, "Non-Stock": 100}


def stage_number(status, stage=None):
    """DealerKit's life_cycle_status as a number from either the number
    itself or the stage's words; None when neither says. Pure, tested."""
    try:
        if status is not None and str(status).strip() != "":
            return int(status)
    except (TypeError, ValueError):
        pass
    return STAGE_NUMBERS.get(str(stage or "").strip())


def locked_price_to_follow(status, here, there):
    """Whether DealerKit's price should come here: only on a stage where
    the price is DealerKit's alone (Ordered, Sold), only when DealerKit
    gives one, and only when it differs from the figure shown here.
    Returns the whole pound figure to set, or None. Pure, tested."""
    if not price_locked({"life_cycle_status": status}):
        return None
    try:
        t = int(round(float(there)))
    except (TypeError, ValueError):
        return None
    try:
        h = int(round(float(here))) if here is not None else None
    except (TypeError, ValueError):
        h = None
    return None if h == t else t


def stage_label(data):
    """DealerKit's stage as words: Due In (5), In Stock (10), Sold (25),
    else DealerKit's own label, else the number."""
    status = (data or {}).get("life_cycle_status")
    if status in STAGE_LABELS:
        return STAGE_LABELS[status]
    return (data or {}).get("life_cycle_status_label") or (str(status) if status is not None else None)


# DealerKit's own stages where the price belongs to DealerKit alone
# (2026-09-07, Steven: "when any car is in ordered or sold the only place
# a price should ever be touched is dealerkit ... hard rule"): Awaiting
# Delivery (20, Ordered here) and Sold (25). DealerKit itself greys the
# Retail Price box out on such a car (DT65UKP, found the same day).
PRICE_LOCKED_STATUSES = (20, 25)


# The stages where a price means anything to us at all: Due In (5), In
# Stock (10), Awaiting Delivery (20), Sold (25). A Non-Stock car (100) is
# not for sale, an old sale back in for warranty work (G13RTP, Steven
# 2026-09-07: "these instances can be ignored"), so its price is left
# alone in both directions and never tried. Anything unknown is treated
# the same way, quietly.
PRICE_SYNC_STATUSES = (5, 10, 20, 25)
NON_STOCK_STATUS = 100


def price_syncable(data):
    """Whether this car's price is ours to keep in step at all, from its
    record or the stored dk_check."""
    try:
        return int((data or {}).get("life_cycle_status")) in PRICE_SYNC_STATUSES
    except (TypeError, ValueError):
        return False


def price_locked(data):
    """Whether a car's price is DealerKit's alone, from its record or the
    stored dk_check (either carries life_cycle_status). Unknown reads as
    not locked so a Due In car is never held back by a missing field."""
    try:
        return int((data or {}).get("life_cycle_status")) in PRICE_LOCKED_STATUSES
    except (TypeError, ValueError):
        return False


# The figures a DealerKit record might hold the deal price under. Nobody
# has seen a sold car's record yet (2026-09-12), so the reader looks for
# the likely names, top level first, then inside an object named for the
# sale, and reports every key it considered so the first real record
# settles the question on Dealer OS (dealKeys on the sale).
SALE_PRICE_KEYS = ("sale_price", "selling_price", "sold_price", "price_sold", "sale_amount", "sold_for", "deal_price", "invoice_total")
SALE_OBJECT_KEYS = ("sale", "deal", "invoice", "sold", "order", "sale_details", "deal_details")
SALE_FIGURE_KEYS = ("sale_price", "selling_price", "total", "amount", "price", "value", "net", "gross", "cash_price", "total_price")


def _figure(x):
    """A price as a whole number of pounds, or None for anything that is not one."""
    if isinstance(x, bool):
        return None
    if isinstance(x, (int, float)):
        return int(round(x)) if x > 0 else None
    if isinstance(x, str):
        t = x.replace("£", "").replace(",", "").strip()
        try:
            v = float(t)
        except ValueError:
            return None
        return int(round(v)) if v > 0 else None
    if isinstance(x, dict):
        for k in SALE_FIGURE_KEYS:
            f = _figure(x.get(k))
            if f is not None:
                return f
    return None


def sale_price_figure(record):
    """What a sold car went for, as its DealerKit record says, and the keys
    that were looked at: (price or None, ["sale_price=10995", ...]).
    A plain figure under one of the known names wins; failing that a
    figure inside an object named for the sale; failing that, any key
    whose name mentions the sale carrying a figure, so the report says
    what the record had even when nothing was recognised. Never a VIN,
    never a name: the keys reported are prices and their names only."""
    d = record or {}
    seen = []
    def note(path, val):
        f = _figure(val)
        if f is not None:
            seen.append(f"{path}={f}")
        elif isinstance(val, dict):
            seen.append(f"{path}={{{','.join(sorted(str(k) for k in val.keys())[:12])}}}")
        elif val is not None and not isinstance(val, (list, dict)):
            seen.append(f"{path}:{type(val).__name__}")
    for k in SALE_PRICE_KEYS:
        if k in d:
            note(k, d[k])
            f = _figure(d[k])
            if f is not None:
                return f, seen
    for k in SALE_OBJECT_KEYS:
        if isinstance(d.get(k), dict):
            note(k, d[k])
            f = _figure(d[k])
            if f is not None:
                return f, seen
    for k, v in d.items():
        kl = str(k).lower()
        if any(w in kl for w in ("sale", "sold", "deal", "invoice")) and k not in SALE_PRICE_KEYS and k not in SALE_OBJECT_KEYS:
            note(str(k), v)
    return None, seen


def list_stocklist_ids(page, status=None, max_pages=400):
    """Every vehicle id on DealerKit's stocklist, 25 a page, filtered to one
    life cycle stage when the stocklist honours the same query its own
    Vehicles page sends (instance.life_cycle_status). Rows that carry a
    stage of their own and do not match are dropped either way, so an
    ignored filter costs pages, never wrong cars."""
    out, offset = [], 0
    for _ in range(max_pages):
        url = f"{DEALERKIT_BASE}/api/stocklist?limit=25&offset={offset}&with[]=vehicle"
        if status is not None:
            url += f"&instance.life_cycle_status={int(status)}"
        j = _get_json(page, url)
        rows = j.get("results") or []
        if not rows:
            break
        for v in rows:
            st = v.get("life_cycle_status")
            if status is not None and st not in (None, "", status, str(status)):
                continue
            if v.get("id") is not None:
                out.append(v["id"])
        offset += 25
        if offset >= (j.get("total") or 0):
            break
    return out


# The names a DealerKit record might give the day a car came in and the
# day it went (2026-09-13, the backfill). Nobody has seen these on a sold
# record yet, so the first present wins and every date shaped key is
# reported with the sale for checking (record_keys).
# On DealerKit's own record (seen 2026-09-13 on four real sales) the day
# a car came in is delivery_on, its Due In date, the same field the due
# in push sets, and the day it went is disposed_on; first_advertised_on
# and created_at sit a day or so either side of the arrival, purchased_at
# is the auction day, updated_at is whenever anyone last touched it. The
# first backfill read delivery_on as the sold day and created_at as the
# arrival, every DealerKit sale a fortnight out; v3.14.77 reads these.
IN_SINCE_KEYS = ("arrived_on", "arrival_date", "in_stock_on", "in_stock_at", "stock_date", "date_in_stock",
                 "delivery_on", "delivered_on", "first_advertised_on", "first_photographed_on", "created_at")
SOLD_ON_KEYS = ("sold_on", "sold_at", "sold_date", "date_sold", "sale_date", "disposed_on", "disposal_date",
                "invoice_date", "updated_at")
IMAGE_KEYS = ("cover_image", "main_image", "primary_image", "image", "images", "photos", "media", "gallery")


def photo_url(record):
    """The first photo url on a DealerKit record, wherever it keeps it:
    cover_image / main_image / primary_image / image as a url or {url},
    the first of images / photos / gallery, the same again under media
    and under vehicle. None when there is none."""
    def first(x):
        if isinstance(x, str):
            return x if x.startswith("http") else None
        if isinstance(x, dict):
            u = x.get("url") or x.get("src") or x.get("href")
            return u if isinstance(u, str) and u.startswith("http") else None
        if isinstance(x, list) and x:
            return first(x[0])
        return None
    seen = []
    for d in (record, record.get("media"), record.get("vehicle"), (record.get("vehicle") or {}).get("media") if isinstance(record.get("vehicle"), dict) else None):
        if isinstance(d, dict) and id(d) not in seen:
            seen.append(id(d))
            for k in IMAGE_KEYS:
                if k == "media":
                    continue
                u = first(d.get(k))
                if u:
                    return u
    return None


def _name_text(x):
    if isinstance(x, dict):
        x = x.get("name") or x.get("label") or x.get("value") or x.get("title") or ""
    return "" if x is None else str(x).strip()


def _int_or_none(x):
    try:
        n = int(float(x))
    except (TypeError, ValueError):
        return None
    return n if n >= 0 else None


def _day(x):
    """YYYY-MM-DD out of an ISO stamp or a date string, else None."""
    if not isinstance(x, str) or len(x) < 10:
        return None
    d = x[:10]
    return d if d[4] == "-" and d[7] == "-" and d[:4].isdigit() else None


def sold_record_report(vehicle_id, record):
    """One sold car as its DealerKit record describes it, in the shape of
    Dealer OS's /api/bidbrain/sales (2026-09-13): the plate, the car, its
    spec and photo, the day it came in, the day it went, and what it went
    for (sold_for). None when the record does not read Sold (25). Pure."""
    d = record or {}
    try:
        status = int(d.get("life_cycle_status"))
    except (TypeError, ValueError):
        return None
    if status != STAGE_NUMBERS["Sold"]:
        return None
    v = d.get("vehicle") or {}
    reg_obj = v.get("registration")
    reg = (reg_obj.get("vrm") if isinstance(reg_obj, dict) else reg_obj) or d.get("vrm") or d.get("registration") or ""
    reg = "".join(str(reg).upper().split())
    make = _name_text(v.get("make") or v.get("manufacturer") or d.get("make"))
    model = _name_text(v.get("model") or d.get("model"))
    derivative = _name_text(v.get("derivative") or v.get("trim"))
    year = v.get("year") or v.get("year_of_manufacture") or ((_day(v.get("registration_date")) or "")[:4] or None)
    name = " ".join(str(x) for x in (year, make.title() if make.isupper() else make, derivative or (model.title() if model.isupper() else model)) if x)
    cc = v.get("engine_size") or v.get("engine_cc")
    try:
        litres = f"{round(float(cc) / 100) / 10:.1f}" if cc else None
    except (TypeError, ValueError):
        litres = None
    fuel = _name_text(v.get("fuel_type") or v.get("fuel")) or None
    gearbox = _name_text(v.get("transmission_type") or v.get("transmission") or v.get("gearbox")) or None
    spec = " · ".join(x for x in (litres, fuel, gearbox) if x) or None
    photo = photo_url(d)
    in_since = next((_day(d.get(k)) for k in IN_SINCE_KEYS if _day(d.get(k))), None)
    sold_on = next((_day(d.get(k)) for k in SOLD_ON_KEYS if _day(d.get(k))), None)
    price, keys = sale_price_figure(d)
    dates = [f"{k}={_day(val)}" for k, val in d.items() if _day(val) and ("date" in str(k) or str(k).endswith("_on") or str(k).endswith("_at"))]
    # Every picture shaped key and its type, so the next read can be
    # checked when no photo was found (2026-09-13: none of 83 records gave one).
    pics = [f"{k}:{type(val).__name__}" for k, val in d.items()
            if any(w in str(k).lower() for w in ("image", "photo", "media", "picture", "gallery"))]
    if isinstance(v, dict):
        pics += [f"vehicle.{k}:{type(val).__name__}" for k, val in v.items()
                 if any(w in str(k).lower() for w in ("image", "photo", "media", "picture", "gallery"))]
    return {
        "api_id": f"dk:{vehicle_id}", "reg": reg, "name": name, "make": make.title() if make.isupper() else make,
        "model": model.title() if model.isupper() else model, "spec": spec, "photo": photo,
        # The car itself (2026-09-14), so a DealerKit sale matches the backfilled ones.
        "mileage": _int_or_none(v.get("mileage")),
        "colour": (lambda c: c.title() if c.isupper() else c)(_name_text(v.get("colour") or v.get("manufacturer_colour"))) or None,
        "in_since": in_since, "sold_on": sold_on, "sold_price": price, "status": status,
        "price_source": "deal", "source": "dealerkit", "record_keys": (keys + dates + pics)[:60],
        "note": None if price is not None else "no figure recognised on the record",
    }


def read_record(page, vehicle_id):
    """One read of a car's DealerKit record, the whole thing (2026-09-07).
    The stage check reads this once per car and takes both the stage and
    the retail price from it, one call, not two."""
    return _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}") or {}


def read_stage(page, vehicle_id):
    """One read of a car's DealerKit record: (life_cycle_status, stage
    words, delivery_on). For the automatic run's "DealerKit wins on
    arrival" pass (2026-09-07)."""
    data = read_record(page, vehicle_id)
    return data.get("life_cycle_status"), stage_label(data), data.get("delivery_on")


def _page_api_headers(page):
    """The three headers DealerKit's own screens put on every call they
    make (found 2026-09-07 in the front end's bundle: X-CSRF-TOKEN and
    X-API-TOKEN from the page's own marque.ui, plus X-Requested-With), so
    a call made through the same signed in browser looks exactly like one
    of DealerKit's own. Empty when the page has no such tokens."""
    try:
        ui = page.evaluate("() => (window.marque && window.marque.ui) || {}") or {}
    except Exception:
        return {}
    out = {"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"}
    if ui.get("csrfToken"):
        out["X-CSRF-TOKEN"] = ui["csrfToken"]
    if ui.get("apiToken"):
        out["X-API-TOKEN"] = ui["apiToken"]
    return out


def set_retail_price_api(page, vehicle_id, price):
    """Set the Retail Price by asking DealerKit's own API the way its
    Pricing panel does when Save is pressed, instead of driving the
    panel (2026-09-07, Steven: "i just want the prices to sync on both
    sides as instant as possible"). The panel route failed on cars whose
    Pricing panel did not show the "£RETAIL" box (DN18JNU, EK68WGV) and on
    one where the search click landed on the Forecourt list (YB67ULM); a
    plain API call has no panel to find. Returns True when the record
    reads back with the new figure, False when DealerKit did not take it
    (a 405, 422 or anything else), so the caller can fall back to the
    panel. Never raises for a refusal; raises only when the session is
    dead, like every other read here."""
    target = int(round(price))
    headers = _page_api_headers(page)
    if "X-CSRF-TOKEN" not in headers:
        return False
    url = f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}"
    for method in ("patch", "put"):
        try:
            r = getattr(page.context.request, method)(url, data={"retail_price": target}, headers=headers, timeout=20000)
        except Exception:
            continue
        if r.status in (200, 201, 204):
            data = read_record(page, vehicle_id)
            if data.get("retail_price") == target:
                return True
        if r.status not in (405, 404):
            break
    return False


def find_vehicle_by_plates(page, *regs):
    """(vehicle_id, the plate it was found under) for the first of these
    plates DealerKit holds, scanning the real stocklist (paginated 25 at a
    time) since there is no direct reg lookup endpoint proven yet. Cheap
    enough for a one off correction; not meant for a hot path. Raises
    DealerkitSessionExpired (rather than a raw JSONDecodeError, found live
    2026-08-26 crashing a real check with no useful message) if the session
    has died.

    Several plates because one car can genuinely have two (2026-09-20,
    Steven's carried over item 1). A seller who keeps their private plate
    leaves the car listed under that plate while it sells, and so reaches
    DealerKit, under the plate underneath, so searching only the plate
    BidBrain recorded read every one of those cars as having no DealerKit
    record at all: no check, no chip, no transport invoice, nothing, for
    good. The whole stocklist is still read only once however many plates
    are given, and the search is ordered, so the recorded plate always
    wins over the plate underneath when DealerKit somehow holds both.
    (None, None) when none of them is there."""
    wanted = plates.candidates(*regs)
    if not wanted:
        return None, None
    found = {}
    offset = 0
    while True:
        j = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist?limit=25&offset={offset}&with[]=vehicle")
        rows = j.get("results") or []
        if not rows:
            break
        for v in rows:
            vrm = plates.tidy(((v.get("vehicle") or {}).get("registration") or {}).get("vrm"))
            if vrm in wanted and vrm not in found:
                found[vrm] = v["id"]
        if len(found) == len(wanted):
            break
        offset += 25
        if offset >= j.get("total", 0):
            break
    for plate in wanted:
        if plate in found:
            return found[plate], plate
    return None, None


def _find_vehicle_id_by_reg(page, reg, alt_regs=()):
    """The DealerKit vehicle id for a car, by the plate it is recorded
    under and then by any other plate it is genuinely known by (see
    find_vehicle_by_plates, which does the work). alt_regs takes one plate
    or several."""
    if isinstance(alt_regs, str):
        alt_regs = (alt_regs,)
    return find_vehicle_by_plates(page, reg, *alt_regs)[0]


def vehicle_id_from_map(reg_map, reg, alt_regs=()):
    """The same lookup against an already read stocklist map (see
    _stocklist_reg_map), for a bulk pass that must not rescan per car.
    Ordered the same way: the recorded plate first, then the plate
    underneath. Returns (vehicle_id, the plate it was found under), or
    (None, None)."""
    if isinstance(alt_regs, str):
        alt_regs = (alt_regs,)
    for plate in plates.candidates(reg, *alt_regs):
        if reg_map.get(plate):
            return reg_map[plate], plate
    return None, None


def other_plate(purchase):
    """The plate underneath a purchase, as something to pass as alt_regs:
    the plate the car sells with when its seller kept a private plate
    (db.purchases.selling_vrm). () when there is none, which is most cars.
    A dict or a sqlite row, since both reach the push passes."""
    if not purchase:
        return ()
    try:
        value = purchase["selling_vrm"]
    except (KeyError, IndexError, TypeError):
        value = None
    return plates.candidates(value)


# ---------------------------------------------------------------------------
# Sent whole once, then kept in step (2026-09-05, Dealer OS's redesigned
# DealerKit tab, relayed by Steven). A car goes to DealerKit in one go once
# its collection date is confirmed (purchase.dk_send); after that only a
# chip, the transport invoice and a check in follow, and a cancelled sale
# is removed. The four follow ups never create a record: a car not on
# DealerKit is "nothing to do", never a fresh Due In.
# ---------------------------------------------------------------------------

def _vehicle_mileage(page, vehicle_id):
    """What DealerKit itself holds as this vehicle's mileage right now
    (vehicle.mileage, confirmed live 2026-09-05 on vehicle 320 alongside
    vehicle.latest_mileage_log_entry.miles), or None."""
    data = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=vehicle")
    v = (data or {}).get("vehicle") or {}
    for cand in (v.get("mileage"), ((v.get("latest_mileage_log_entry") or {}).get("miles"))):
        if isinstance(cand, (int, float)):
            return int(cand)
        if isinstance(cand, str) and cand.strip().isdigit():
            return int(cand)
    return None


def add_mileage_log_entry(page, vehicle_id, miles, note="Mileage from BidBrain"):
    """Write the real mileage onto a DealerKit record. DealerKit fills in
    its OWN figure the moment a car is created ("Added to stock", 36000 on
    the fake car this was built against, a guess by DealerKit, not a fact
    anyone typed), and Steven's standing rule is that DealerKit is never
    allowed to guess the mileage, so every car sent whole gets BidBrain's
    own figure written over it here. The real UI path, found live
    2026-09-05: Edit Vehicle > "Mileage Log" tile > ADD > "Add Mileage Log
    Entry" (Date defaults to today, Mileage, Notes, an "Add service
    entry?" switch left off) > SAVE. Verified through the API afterwards
    (vehicle.mileage), retried once, raised loudly otherwise. Returns
    {"old": ..., "new": miles}."""
    miles = int(miles)
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2000)
    old = _vehicle_mileage(page, vehicle_id)
    if old == miles:
        return {"old": old, "new": miles, "changed": False}

    if not _click_containing(page, "EDIT"):
        raise RuntimeError(f"DealerKit: the Edit button was not found (vehicle {vehicle_id}).")
    page.wait_for_timeout(1500)
    if not _click_visible_text(page, "Mileage Log", exact=True, within=".ui-modal.is-open"):
        raise RuntimeError(f"DealerKit: the Mileage Log tile was not found (vehicle {vehicle_id}).")
    page.wait_for_timeout(1500)
    # The ADD button shares its text node with an icon ligature, the same
    # shape as EDIT, so it is matched by containing text inside the open
    # modal, smallest visible element wins.
    add_pos = _evaluate_until(
        page,
        """() => {
            const els = document.querySelectorAll('.ui-modal.is-open button, .ui-modal.is-open a');
            const out = [];
            for (const el of els) {
                const t = (el.innerText || '').toUpperCase().replace(/\\s+/g, '');
                if (!t.includes('ADD')) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) out.push({x: r.x + r.width/2, y: r.y + r.height/2, area: r.width*r.height});
            }
            out.sort((a, b) => a.area - b.area);
            return out[0] || null;
        }""",
    )
    if not add_pos:
        raise RuntimeError(f"DealerKit: the Mileage Log ADD button was not found (vehicle {vehicle_id}).")
    page.mouse.click(add_pos["x"], add_pos["y"])
    page.wait_for_timeout(1200)

    # The Mileage field: the visible label reading exactly "Mileage" in the
    # topmost open dialog, then the nearest visible .ui-textbox__input by
    # 2D distance (the Date field's own input sits beside it, so distance
    # on both axes at once is what tells them apart). Marked so Playwright
    # can fill it the way a person types, then blurred so Vue's own state
    # picks the value up (the same lesson as _fill_purchase_price).
    marked = _evaluate_until(
        page,
        """() => {
            const modals = Array.from(document.querySelectorAll('.ui-modal.is-open'));
            const dlg = modals.reverse().find(m => (m.innerText || '').includes('Mileage Log Entry')) || modals[0];
            if (!dlg) return false;
            let label = null;
            for (const el of dlg.querySelectorAll('*')) {
                if (el.children.length > 2) continue;
                if ((el.textContent || '').trim() !== 'Mileage') continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) { label = {x: r.x + r.width/2, y: r.y + r.height/2}; break; }
            }
            if (!label) return false;
            let best = null, bestDist = Infinity;
            for (const inp of dlg.querySelectorAll('input.ui-textbox__input, input[type=text], input[type=number]')) {
                const r = inp.getBoundingClientRect();
                if (r.width === 0 || r.height === 0) continue;
                const d = Math.hypot(r.x + r.width/2 - label.x, r.y + r.height/2 - label.y);
                if (d < bestDist) { bestDist = d; best = inp; }
            }
            if (!best) return false;
            document.querySelectorAll('[data-bb-mileage]').forEach(e => e.removeAttribute('data-bb-mileage'));
            best.setAttribute('data-bb-mileage', '1');
            return true;
        }""",
    )
    if not marked:
        raise RuntimeError(f"DealerKit: the Mileage field was not found (vehicle {vehicle_id}).")
    field = page.locator("[data-bb-mileage]")
    field.click()
    field.fill(str(miles))
    field.blur()
    page.wait_for_timeout(300)
    if note:
        page.evaluate(
            """(note) => {
                const modals = Array.from(document.querySelectorAll('.ui-modal.is-open'));
                const dlg = modals.reverse().find(m => (m.innerText || '').includes('Mileage Log Entry')) || modals[0];
                const ta = dlg && Array.from(dlg.querySelectorAll('textarea')).find(t => t.getBoundingClientRect().width > 0);
                if (!ta) return false;
                ta.setAttribute('data-bb-mileage-note', '1');
                return true;
            }""",
            note,
        )
        notes = page.locator("[data-bb-mileage-note]")
        if notes.count():
            notes.first.fill(note)
            notes.first.blur()

    for attempt in (1, 2):
        save_pos = _evaluate_until(
            page,
            """() => {
                const modals = Array.from(document.querySelectorAll('.ui-modal.is-open'));
                const dlg = modals.reverse().find(m => (m.innerText || '').includes('Mileage Log Entry')) || modals[0];
                if (!dlg) return null;
                const out = [];
                for (const b of dlg.querySelectorAll('button')) {
                    const t = (b.innerText || '').toUpperCase().replace(/\\s+/g, '');
                    if (t !== 'SAVE' && !t.endsWith('SAVE')) continue;
                    const r = b.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) out.push({x: r.x + r.width/2, y: r.y + r.height/2, area: r.width*r.height});
                }
                out.sort((a, b) => a.area - b.area);
                return out[0] || null;
            }""",
            timeout=4.0,
        )
        if save_pos:
            page.mouse.click(save_pos["x"], save_pos["y"])
        page.wait_for_timeout(2000)
        got = _vehicle_mileage(page, vehicle_id)
        if got == miles:
            return {"old": old, "new": miles, "changed": True}
        if attempt == 2:
            raise RuntimeError(
                f"DealerKit: the mileage did not actually save for vehicle {vehicle_id} "
                f"(still {got!r}, wanted {miles}).")


def _read_supplier_items(page, vehicle_id, supplier):
    """The line items DealerKit holds on this vehicle's expense from the
    given supplier, keyed the way _EXPENSE_CATEGORY_LABEL keys them, each
    the item's own unit_net (the figure a person types; DealerKit adds the
    VAT itself). Returns (contact name as DealerKit spells it, {key:
    amount}). The purchase price line is expense_category_id 2 whatever its
    description says (confirmed live 2026-09-05: "FX68KAE chassis" on one,
    the whole vehicle name on another); the fee lines are matched by their
    description text, the same way _expense_categories_present already
    does. Supplier matched case insensitively: this account spells the
    contact "Carwow", the code's own default says "CarWow"."""
    data = _get_json(
        page,
        f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}?with[]=expenseItems"
        "&with[]=expenseItems.expense&with[]=expenseItems.expense.contact")
    found, name_found = {}, None
    for it in (data or {}).get("expense_items") or []:
        contact = (((it.get("expense") or {}).get("contact") or {}).get("company_name") or "")
        if not _same_contact(contact, supplier):
            continue
        name_found = contact
        amount = it.get("unit_net")
        try:
            amount = float(amount)
        except (TypeError, ValueError):
            amount = _expense_item_amount(it)
        if it.get("expense_category_id") == 2:
            found.setdefault("chassis", amount)
            continue
        desc = (it.get("description") or "").lower()
        for key, label in _EXPENSE_CATEGORY_LABEL.items():
            if key != "chassis" and label.lower() in desc:
                found.setdefault(key, amount)
    return name_found, found


def _amount_matches(have, want):
    return have is not None and want is not None and abs(float(have) - float(want)) < 0.01


def set_expense_items(page, vehicle_id, items, supplier):
    """Set one or more lines on the supplier's purchase expense to exact
    figures: a line already there under that category is deleted client
    side and re added at the new figure in the same unsaved session (the
    mechanism set_purchase_price proved live), a line not there yet is
    simply added, then one Save & Approve and a read back through the API.
    items is {"chassis"|"buyers_premium"|"delivery_charge"|"indemnity_fee":
    amount}. A vehicle with no expense from this supplier at all is
    refused with a plain sentence rather than guessed at: the price goes
    on first, and that creates the expense."""
    items = {k: float(v) for k, v in items.items()}
    if not items:
        return {}
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(1500)
    contact, before = _read_supplier_items(page, vehicle_id, supplier)
    if contact is None:
        raise RuntimeError(
            f"DealerKit has no purchase expense from {supplier} on this car yet, so the "
            f"{', '.join(_EXPENSE_CATEGORY_LABEL[k] for k in items)} cannot be set. Send the purchase price first.")
    if all(_amount_matches(before.get(k), v) for k, v in items.items()):
        return {k: {"old": before.get(k), "new": v, "changed": False} for k, v in items.items()}

    _open_supplier_expense(page, vehicle_id, contact)

    # Only a line that is there AND differs is deleted and re added; a
    # line already at the figure is left alone and a missing one is just
    # added (2026-09-16, ND66XYJ: the fee already matched and the delete
    # of its line failed, so the missing price never got added).
    todo = {k: v for k, v in items.items() if not _amount_matches(before.get(k), v)}
    for key in todo:
        if key not in before:
            continue
        label = _EXPENSE_CATEGORY_LABEL[key]
        kebab = page.evaluate(
            """(label) => {
                // The Edit Expense window itself (the survey, 2026-09-16:
                // each line is a table row with its own more_vert), never
                // the page behind it.
                const opens = document.querySelectorAll('.ui-modal.is-open');
                const m = opens.length ? opens[opens.length - 1] : document.body;
                const els = m.querySelectorAll('td, tr, div, span, li');
                const matches = [];
                for (const el of els) {
                    if (el.children.length > 10) continue;
                    const t = (el.innerText || '').trim();
                    if (!t.toLowerCase().includes(label.toLowerCase())) continue;
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) matches.push({el, area: r.width * r.height});
                }
                if (!matches.length) return null;
                matches.sort((a, b) => a.area - b.area);
                let node = matches[0].el;
                for (let i = 0; i < 8 && node; i++) {
                    const btns = Array.from(node.querySelectorAll('button'));
                    const k = btns.find(b => (b.innerText || '').includes('more_vert'));
                    if (k) {
                        k.scrollIntoView({block: 'center'});
                        const r = k.getBoundingClientRect();
                        if (r.width > 0 && r.height > 0)
                            return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                    }
                    node = node.parentElement;
                }
                return null;
            }""",
            label,
        )
        if not kebab:
            raise RuntimeError(
                f"DealerKit: the {label} line's own menu was not found (vehicle {vehicle_id}). {_screen_words(page)}")
        page.mouse.click(kebab["x"], kebab["y"])
        page.wait_for_timeout(900)
        if not _click_visible_text(page, "Delete", exact=True):
            raise RuntimeError(
                f"DealerKit: Delete was not offered on the {label} line (vehicle {vehicle_id}). {_screen_words(page)}")
        page.wait_for_timeout(900)

    _add_expense_items(page, todo, vehicle_id)

    for attempt in (1, 2):
        if not _click_visible_text(page, "Save & Approve", exact=True):
            raise RuntimeError(f"DealerKit: Save & Approve was not found (vehicle {vehicle_id}). {_screen_words(page)}")
        page.wait_for_timeout(2500)
        _, after = _read_supplier_items(page, vehicle_id, supplier)
        if all(_amount_matches(after.get(k), v) for k, v in items.items()):
            # A line left alone says so (2026-09-16, ND66XYJ read "buyer's
            # fee changed from £289 to £289").
            return {k: {"old": before.get(k), "new": v, "changed": k in todo} for k, v in items.items()}
        if attempt == 2:
            raise RuntimeError(
                f"DealerKit: the expense lines did not actually save for vehicle {vehicle_id} "
                f"(now {after}, wanted {items}).")


def check_in_vehicle(page, vehicle_id, date_iso=None):
    """Check a Due In car in on DealerKit, so it reads In Stock there
    (life_cycle_status 5 to 10). What "checked in" means on the DealerKit
    side, found live 2026-09-05: the car has arrived and is stock, no
    longer expected, with the arrival date on its record. DealerKit has two
    ways there and this uses whichever the record allows:

    - A record with no Due In / In-stock On date (the normal case since
      v3.14.2, the due in date is no longer sent) shows a Due In banner on
      its overview with a CHECK-IN button ("Check-in / Move": Delivered On,
      Physical Location defaulting to Onsite, CHECK-IN). DealerKit's own
      code behind that button (read from its page script 2026-09-05) saves
      the chosen day as the record's delivery_on, so the button and the
      date field are the same action in DealerKit's eyes.
    - A record that already carries a due in date hides that banner, and
      DealerKit moves the car to In Stock by itself the moment the date is
      today or past. So for such a record the check in is written as the
      arrival date on the Due In / In-stock On field (set_delivery_date).

    date_iso is the arrival day (today when not given). Returns True when
    the car moved, False when DealerKit already had it In Stock; raises a
    plain sentence for any other stage (a sold car is never touched)."""
    data = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}")
    status = (data or {}).get("life_cycle_status")
    if status == 10:
        return False
    if status != 5:
        raise RuntimeError(
            f"DealerKit shows this car as {(data or {}).get('life_cycle_status_label') or status}, "
            "not Due In, so it was not checked in.")
    date_iso = date_iso or datetime.date.today().isoformat()

    def _status():
        return (_get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}") or {}).get("life_cycle_status")

    if (data or {}).get("delivery_on"):
        set_delivery_date(page, vehicle_id, date_iso)
        for _ in range(4):
            if _status() == 10:
                return True
            page.wait_for_timeout(1500)

    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview",
              wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)
    # The lower half of the overview (the Due In banner with its CHECK-IN
    # button, Vehicle Details, Financials...) is drawn only once it scrolls
    # into view: DealerKit's own component keeps a loader up until an
    # intersection observer fires (found in its page script 2026-09-05,
    # visibilityChanged -> showLoader = false). A record with a photo puts
    # that half below the fold, so a page that is never scrolled never
    # shows the button, however long it is left. Scroll like a person would
    # and wait for the body to appear before looking for the button.
    for _ in range(6):
        if page.evaluate("() => /Vehicle Details/.test(document.body.innerText || '')"):
            break
        page.mouse.wheel(0, 2500)
        page.wait_for_timeout(1200)
    # Click the banner's own button through Playwright (scrolled into view
    # first): a coordinate click lands nowhere when the button sits just
    # below the viewport, which is exactly where the scroll above leaves it.
    clicked = False
    banner_btn = page.locator("button", has_text=re.compile(r"check-?in", re.I))
    try:
        for i in range(banner_btn.count()):
            b = banner_btn.nth(i)
            if b.is_visible():
                b.scroll_into_view_if_needed()
                b.click(timeout=8000)
                clicked = True
                break
    except Exception:
        clicked = False
    if not clicked:
        if _status() == 10:
            return True
        seen = page.evaluate(
            """() => Array.from(document.querySelectorAll('button, a'))
                .filter(e => e.getBoundingClientRect().width > 0)
                .map(e => (e.innerText || '').replace(/\\s+/g, ' ').trim()).filter(t => t && t.length < 30)""")
        raise RuntimeError(f"DealerKit: the CHECK-IN button was not found (vehicle {vehicle_id}); "
                           f"the page showed {seen[:16]} at {page.url}.")
    page.wait_for_timeout(1500)
    _click_ui_select_field(page, "Delivered On")
    _pick_calendar_date(page, date_iso, "Delivered On")
    page.wait_for_timeout(400)

    for attempt in (1, 2):
        pos = _evaluate_until(
            page,
            """() => {
                const out = [];
                for (const b of document.querySelectorAll('.ui-modal.is-open button')) {
                    const t = (b.innerText || '').toUpperCase().replace(/\\s+/g, '');
                    if (!t.includes('CHECK-IN') && !t.includes('CHECKIN')) continue;
                    const r = b.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) out.push({x: r.x + r.width/2, y: r.y + r.height/2, area: r.width*r.height});
                }
                out.sort((a, b) => a.area - b.area);
                return out[0] || null;
            }""",
            timeout=4.0,
        )
        if pos:
            page.mouse.click(pos["x"], pos["y"])
        page.wait_for_timeout(2500)
        if _status() == 10:
            return True
        if attempt == 2:
            raise RuntimeError(
                f"DealerKit: the check in did not actually save for vehicle {vehicle_id} "
                f"(status still {_status()!r}).")


def remove_vehicle_record(page, vehicle_id):
    """Remove a cancelled sale's record from DealerKit. Only a car still
    Due In is deleted (delete_vehicle, the real kebab > Delete > confirm
    path); a car DealerKit already shows as In Stock or beyond has been
    handled on that side and is left alone with a plain sentence, the
    closest safe thing to a delete that cannot be done. Returns a
    sentence saying what happened; raises only on a genuine failure."""
    try:
        data = _get_json(page, f"{DEALERKIT_BASE}/api/stocklist/{vehicle_id}")
    except DealerkitRecordGone:
        # Deleted on DealerKit by hand already (2026-09-09, the S17OUG walk
        # through): the outcome a remove wants, so it counts as done and
        # the row reads removed rather than stuck on DealerKit for good.
        return f"already gone from DealerKit, removed there by hand (vehicle {vehicle_id})"
    status = (data or {}).get("life_cycle_status")
    label = (data or {}).get("life_cycle_status_label") or status
    if status != 5:
        raise RuntimeError(
            f"DealerKit shows this car as {label}, not Due In, so it was left in place. "
            "Remove it in DealerKit by hand if that is right.")
    delete_vehicle(page, vehicle_id, confirm=True)
    return f"removed from DealerKit (it was Due In, vehicle {vehicle_id})"


FOLLOWUP_ACTIONS = ("chip", "transport", "checkin", "remove", "figures")

# The follow ups that can only ever be a follow up, so must be sent on
# their own: everything in FOLLOWUP_ACTIONS that a whole send cannot also
# carry. Since 2026-09-07 that is every one of them except the delivery
# charge, which now rides along with the car as well.
FOLLOWUP_ONLY = tuple(a for a in FOLLOWUP_ACTIONS if a not in SEND_ITEMS)


def push_followup(page, purchase, action, fill_only=False):
    """One of the four things that follow a car already on DealerKit
    (FOLLOWUP_ACTIONS). Never creates a record: a car with no DealerKit
    record is reported as nothing to do. Returns {"vehicle_id", "done",
    "outcome"}; raises with a plain sentence when DealerKit refused or
    the save did not stick."""
    reg = purchase["reg"]
    vehicle_id = _find_vehicle_id_by_reg(page, reg, other_plate(purchase))
    if not vehicle_id:
        return {"reg": reg, "vehicle_id": None, "done": False,
                "outcome": "not on DealerKit, nothing to do"}
    supplier = _default_supplier(purchase)

    if action in ("chip", "figures"):
        # "figures" (2026-09-14): the price or buyer's fee on file has
        # moved since it was sent, or never landed (2026-09-16), the same
        # correction a chip makes. One route, sync_purchase_expense: it
        # creates the purchase expense when the car has none.
        price = purchase.get("car_price")
        if price is None:
            return {"reg": reg, "vehicle_id": vehicle_id, "done": False,
                    "outcome": "nothing to send, BidBrain has no purchase price for this car"}
        fee = purchase.get("effective_fee")
        items = {"chassis": price}
        if fee is not None:
            items["buyers_premium"] = fee
        left = None
        if fill_only:
            # The automatic run filling in a price that never landed
            # (db.dk_figures_missing) never overwrites a figure a person
            # typed into DealerKit; a press of Update the price and fee
            # in Dealer OS does.
            _, current = _read_supplier_items(page, vehicle_id, supplier)
            if current.get("chassis") is not None and not _amount_matches(current["chassis"], price):
                items.pop("chassis")
                left = (f"purchase price left alone, DealerKit holds £{float(current['chassis']):g} typed there "
                        f"and BidBrain says £{float(price):g}; press Update the price and fee to replace it")
        res = sync_purchase_expense(page, vehicle_id, supplier, items)
        parts = [left] if left else []
        for key in ("chassis", "buyers_premium"):
            if key in res:
                parts.append(f"{FIGURE_WORDS[key]} {figure_words(res[key], supplier)}")
        done = all(figure_landed(r) for r in res.values()) and not left
        return {"reg": reg, "vehicle_id": vehicle_id, "done": done,
                "left": bool(left) or any(r.get("held") for r in res.values()),
                "outcome": ", ".join(parts)}

    if action == "transport":
        fee = purchase.get("transport_fee")
        if fee is None:
            return {"reg": reg, "vehicle_id": vehicle_id, "done": False,
                    "outcome": "nothing to send, BidBrain has no transport fee for this car"}
        res = sync_purchase_expense(page, vehicle_id, supplier, {"delivery_charge": fee})
        r = res["delivery_charge"]
        return {"reg": reg, "vehicle_id": vehicle_id, "done": figure_landed(r),
                "left": bool(r.get("held")),
                "outcome": "transport " + figure_words(r, supplier)}

    if action == "checkin":
        when = (purchase.get("checked_in_at") or "")[:10] or None
        moved = check_in_vehicle(page, vehicle_id, when)
        if moved:
            return {"reg": reg, "vehicle_id": vehicle_id, "done": True,
                    "outcome": f"checked in on DealerKit, Due In to In Stock, delivered {when or 'today'}"}
        # DealerKit had it In Stock already: its arrival date is the truth
        # (2026-09-07), carried back so BidBrain's own arrival time follows.
        _, _, delivered = read_stage(page, vehicle_id)
        return {"reg": reg, "vehicle_id": vehicle_id, "done": True, "delivered_on": delivered,
                "outcome": "already In Stock on DealerKit" + (f", delivered {delivered[:10]}" if delivered else "")}

    if action == "remove":
        return {"reg": reg, "vehicle_id": vehicle_id, "done": True,
                "outcome": remove_vehicle_record(page, vehicle_id)}

    raise ValueError(f"unknown DealerKit follow up {action!r}")


# ---------------------------------------------------------------------------
# The costs screen survey (2026-09-16): look, write down, save nothing.
# ---------------------------------------------------------------------------

def _survey_dump(page, say, label, shot=False, logs_dir=None):
    """Everything the topmost open DealerKit window shows right now, in
    lines of at most 380 characters: its text, every visible button with
    its class, every field label, hint and value with its tag and class,
    every visible dropdown option, and every visible input. For the
    survey and for nothing else. shot=True (2026-09-16, the key screens
    only, to keep the file small enough for diagnostics' own tail limit)
    also appends a real picture of the window."""
    top = """(() => { const opens = document.querySelectorAll('.ui-modal.is-open'); return opens.length ? opens[opens.length - 1] : document.body; })()"""
    def ev(js):
        try:
            return page.evaluate(js) or []
        except Exception as e:
            return [f"(not read: {str(e)[:80]})"]
    say(f"== {label}  {page.url}")
    text = " ".join(str(ev("(" + top + ").innerText || ''") or "").split())
    for i in range(0, min(len(text), 6000), 350):
        say("text: " + text[i:i + 350])
    btns = ev("""Array.from((%s).querySelectorAll('button, a.btn, a.pill-green, [role=button]'))
        .filter(b => { const r = b.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
        .map(b => (b.tagName.toLowerCase() + '.' + (typeof b.className === 'string' ? b.className.split(' ').slice(0, 3).join('.') : '') + ' "' + (b.innerText || '').trim().replace(/\\s+/g, ' ') + '"'))
        .slice(0, 60)""" % top)
    for i in range(0, len(btns), 4):
        say("buttons: " + " | ".join(btns[i:i + 4])[:380])
    fields = ev("""Array.from((%s).querySelectorAll('.ui-select, .ui-textbox, .ui-datepicker, select, textarea, input'))
        .filter(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
        .map(el => {
            const lab = el.querySelector ? (el.querySelector('.ui-select__label-text, .ui-textbox__label-text, .ui-datepicker__label-text') || {}).textContent : '';
            const disp = el.querySelector ? (el.querySelector('.ui-select__display-value, .ui-select__display') || {}).textContent : '';
            const inp = el.tagName.toLowerCase() === 'input' ? el : (el.querySelector ? el.querySelector('input') : null);
            const ph = inp ? (inp.getAttribute('placeholder') || '') : (el.getAttribute('placeholder') || '');
            const val = inp ? (inp.value || '') : (el.value || '');
            return el.tagName.toLowerCase() + '.' + (typeof el.className === 'string' ? el.className.split(' ').slice(0, 2).join('.') : '')
                + ' label="' + (lab || '').trim() + '" shows="' + (disp || '').trim() + '" hint="' + ph + '" value="' + String(val).slice(0, 30) + '"';
        }).slice(0, 60)""" % top)
    for i in range(0, len(fields), 3):
        say("fields: " + " | ".join(fields[i:i + 3])[:380])
    opts = ev("""Array.from(document.querySelectorAll('.ui-select-option, .ui-select__options li, .ui-select-option__basic, [role=option], .dropdown-menu li, .dropdown-item'))
        .filter(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
        .map(el => (el.textContent || '').trim().replace(/\\s+/g, ' ')).slice(0, 80)""")
    for i in range(0, len(opts), 6):
        say("options: " + " | ".join(opts[i:i + 6])[:380])
    if shot and logs_dir:
        _append_shot(logs_dir, "dealerkit_survey.log", label, page)
    cats = ev("""Array.from((%s).querySelectorAll('*'))
        .filter(el => el.children.length <= 2 && /categor/i.test((el.innerText || '') + ' ' + (el.getAttribute('placeholder') || '') + ' ' + (el.getAttribute('aria-label') || '')))
        .filter(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; })
        .map(el => el.tagName.toLowerCase() + '.' + (typeof el.className === 'string' ? el.className.split(' ').slice(0, 3).join('.') : '') + ' "' + ((el.innerText || el.getAttribute('placeholder') || '').trim().replace(/\\s+/g, ' ')).slice(0, 60) + '"')
        .slice(0, 20)""" % top)
    for i in range(0, len(cats), 3):
        say("category words: " + " | ".join(cats[i:i + 3])[:380])


def survey_expense_form(page, reg, supplier, say):
    logs_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "logs")
    """Walk the costs screen of one car that has no purchase expense yet,
    writing every screen down through say(), and save nothing (2026-09-16,
    Steven: "learn the steps and screens properly"). Opens Edit Vehicle >
    Expenses, presses + EXPENSE, picks a team and the supplier in the
    unsaved form, presses the item ADD, opens the category box, and
    writes each screen down as it goes. Ends by leaving the page, which
    discards the unsaved form. Never presses Save, Save & Approve or the
    item's own Add."""
    vehicle_id = _find_vehicle_id_by_reg(page, reg)
    if not vehicle_id:
        say(f"{reg} is not on DealerKit, nothing to survey.")
        return
    say(f"Survey of {reg}, DealerKit vehicle {vehicle_id}, supplier {supplier!r}")
    contact, items = _read_supplier_items(page, vehicle_id, supplier)
    amount, holder = _purchase_cost_holder(page, vehicle_id)
    say(f"API: expense from {supplier!r}: {contact!r} {items}; purchase cost on record: {amount!r} under {holder!r}")
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(2500)
    _open_edit_tile(page, vehicle_id, "Expenses")
    page.wait_for_timeout(1500)
    _survey_dump(page, say, "S1 Edit Vehicle > Expenses")
    expense_pos = page.evaluate(
        """() => {
            const btns = Array.from(document.querySelectorAll('.ui-modal.is-open button'));
            for (const b of btns) {
                const t = (b.innerText || '').toUpperCase();
                if (t.includes('EXPENSE') && !t.includes('CREDIT')) {
                    const r = b.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if not expense_pos:
        say("No + EXPENSE button found; stopping here.")
        return
    page.mouse.click(expense_pos["x"], expense_pos["y"])
    page.wait_for_timeout(1800)
    _survey_dump(page, say, "S2 after + EXPENSE (the Add Expense form)")
    team_pos = page.evaluate(
        """() => {
            const els = document.querySelectorAll('div, span');
            for (const el of els) {
                if (el.children.length > 2) continue;
                if ((el.textContent || '').trim() === 'Select team') {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if team_pos:
        page.mouse.click(team_pos["x"], team_pos["y"])
        page.wait_for_timeout(800)
        _survey_dump(page, say, "S3 team list open")
        team, why = _choose_team(_visible_dropdown_options(page), _preferred_team())
        say(f"team chosen: {team!r} ({why})")
        if team:
            try:
                _click_dropdown_option(page, team)
            except Exception as e:
                say(f"team click failed: {str(e)[:200]}")
        page.wait_for_timeout(600)
    else:
        say("No 'Select team' field seen.")
    sup_pos = page.evaluate(
        """() => {
            const els = document.querySelectorAll('div, span');
            for (const el of els) {
                if (el.children.length > 2) continue;
                if ((el.textContent || '').trim() === 'Select contact') {
                    const r = el.getBoundingClientRect();
                    if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2};
                }
            }
            return null;
        }"""
    )
    if sup_pos:
        page.mouse.click(sup_pos["x"], sup_pos["y"])
        page.wait_for_timeout(800)
        page.keyboard.type(supplier)
        page.wait_for_timeout(1500)
        _survey_dump(page, say, f"S4 supplier list after typing {supplier!r}")
        opt = page.evaluate(
            """(supplier) => {
                let els = Array.from(document.querySelectorAll('.ui-select-option, .ui-select__options li, [role=option]'));
                if (!els.some(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }))
                    els = Array.from(document.querySelectorAll('.ui-modal.is-open li, .ui-modal.is-open [role=option], .ui-modal.is-open div'));
                const s = supplier.toLowerCase();
                let best = null, bestLen = Infinity;
                for (const el of els) {
                    if (el.children.length > 2) continue;
                    const t = (el.textContent || '').trim();
                    if (t.toLowerCase().startsWith(s) && t.length < bestLen) {
                        const r = el.getBoundingClientRect();
                        if (r.width > 0 && r.height > 0) { best = {x: r.x + r.width / 2, y: r.y + r.height / 2, text: t}; bestLen = t.length; }
                    }
                }
                return best;
            }""",
            supplier,
        )
        say(f"supplier option: {opt!r}")
        if opt:
            page.mouse.click(opt["x"], opt["y"])
            page.wait_for_timeout(800)
    else:
        say("No 'Select contact' field seen.")
    _survey_dump(page, say, "S5 form after team and supplier")
    cands = page.evaluate(
        """() => {
            const btns = Array.from(document.querySelectorAll('.ui-modal.is-open button'));
            const out = [];
            for (const b of btns) {
                const t = (b.innerText || '').trim();
                if (!t.toLowerCase().includes('add')) continue;
                const r = b.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) out.push({text: t, ui: b.classList.contains('ui-button'), cls: b.className, x: r.x + r.width/2, y: r.y + r.height/2, area: r.width*r.height});
            }
            return out;
        }"""
    ) or []
    say("add buttons: " + " | ".join(f"{c['text']!r} ui={c['ui']} cls={c['cls'][:40]!r} area={int(c['area'])}" for c in cands)[:380])
    pick = _choose_add_button(cands)
    say(f"ADD chosen: {cands[pick]['text']!r}" if pick is not None else "no ADD chosen")
    if pick is None:
        return
    page.mouse.click(cands[pick]["x"], cands[pick]["y"])
    page.wait_for_timeout(1500)
    _survey_dump(page, say, "S6 after the item ADD", shot=True, logs_dir=logs_dir)
    cat = page.evaluate(
        """() => {
            const opens = document.querySelectorAll('.ui-modal.is-open'); const m = opens.length ? opens[opens.length - 1] : document.body;
            for (const el of m.querySelectorAll('*')) {
                if (el.children.length > 3) continue;
                const words = (el.innerText || '') + ' ' + (el.getAttribute('placeholder') || '') + ' ' + (el.getAttribute('aria-label') || '');
                if (!/categor/i.test(words)) continue;
                const r = el.getBoundingClientRect();
                if (r.width > 0 && r.height > 0) return {x: r.x + r.width / 2, y: r.y + r.height / 2, tag: el.tagName, cls: el.className, words: words.trim().slice(0, 80)};
            }
            return null;
        }"""
    )
    say(f"category control: {cat!r}")
    if cat:
        page.mouse.click(cat["x"], cat["y"])
        page.wait_for_timeout(1000)
        _survey_dump(page, say, "S7 category box open")
        for group in _EXPENSE_CATEGORY_GROUPS:
            if _click_visible_text(page, group, exact=False, timeout=3.0):
                say(f"group clicked: {group!r}")
                page.wait_for_timeout(800)
                _survey_dump(page, say, f"S8 after group {group!r}")
                break
        else:
            say("no group text found; typing Chassis")
            try:
                page.keyboard.type("Chassis")
                page.wait_for_timeout(900)
            except Exception:
                pass
            _survey_dump(page, say, "S8 after typing Chassis")
    else:
        say("no category words on the form")
    if contact is not None:
        # The existing expense's own edit screen, for the delete and
        # re add of a line that differs (a chip, a corrected fee).
        try:
            page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview", wait_until="domcontentloaded", timeout=45000)
            page.wait_for_timeout(1500)
            _open_supplier_expense(page, vehicle_id, contact)
            _survey_dump(page, say, f"S9 Edit Expense for {contact!r}", shot=True, logs_dir=logs_dir)
            rows_info = page.evaluate(
                """() => { const opens = document.querySelectorAll('.ui-modal.is-open'); const m = opens.length ? opens[opens.length - 1] : document.body;
                    return Array.from(m.querySelectorAll('tr, .item, .row')).filter(r => /Chassis|Premium|Delivery/i.test(r.innerText || ''))
                        .filter(r => { const b = r.getBoundingClientRect(); return b.width > 0 && b.height > 0; })
                        .map(r => r.tagName.toLowerCase() + ' "' + (r.innerText || '').trim().replace(/\s+/g, ' ').slice(0, 120) + '" buttons: ' +
                            Array.from(r.querySelectorAll('button')).map(b => (b.innerText || '').trim()).join('/')).slice(0, 8); }"""
            ) or []
            for r in rows_info:
                say("line: " + r[:380])
        except Exception as e:
            say(f"S9 not reached: {str(e)[:200]}")
    say("Survey done. Leaving the page without saving.")
    page.goto(f"{DEALERKIT_BASE}/vehicles/{vehicle_id}/overview", wait_until="domcontentloaded", timeout=45000)
    page.wait_for_timeout(1500)
    _, items_after = _read_supplier_items(page, vehicle_id, supplier)
    amount_after, _ = _purchase_cost_holder(page, vehicle_id)
    say(f"After: expense lines {items_after}, purchase cost on record {amount_after!r} (must be unchanged)")
