"""The shortlist deep read (Dealer OS v4.1.0, 2026-09-17).

Dealer OS shows one starred car at a time with everything about it on
one page. Most of that is already in the push; what is not, the listing
page carries and the daily run walks past: the MOT and its advisories,
the service book, the keepers, colour, keys, tyres, declared damage, the
platform's own history check and every photo at full size. Reading that
for hundreds of cars a day is not worth it, reading it for the handful a
person starred is. So Dealer OS logs a shortlist.read action naming only
those cars, and this Mac reads only those pages.

This module is the pure half: given what a page showed (Motorway's page
data, Carwow's and DealerWay's visible words, Auction4Cars' HTML) it
returns the one `deep` block the push carries per car, in the exact
shape BIDBRAIN_PUSH_CONTRACT.md "Shortlist deep read" gives. Nothing
here opens a browser, so every parser is unit tested against fixture
text. Golden rule 4 throughout: a field the page did not show is None,
never a guess, and Dealer OS shows "not read" for it.

The readers (bidbrain/readers/*.deep_read) do the page visit and hand
in the words; daily_run.deep_read_pass walks the queue.
"""
import datetime
import json
import re

# The one shape Dealer OS reads. Every key is present on every answer so
# the screen never has to wonder whether a missing key means "not read"
# or "an older Mac".
FIELDS = ("read_at", "asked_at", "error", "mot_expiry", "mot_tested", "mot_result", "advisories",
          "service", "keepers", "colour", "interior", "keys", "damage", "tyres_mm", "tyre_notes",
          "warning_lights", "modifications", "history_check", "photos", "reserve_now", "bid_now")

MONEY = re.compile(r"£\s?(\d[\d,]*)")
_MONTHS = {m: i for i, m in enumerate(
    ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)}


def empty(asked_at=None, read_at=None, error=None):
    """A deep block with nothing read yet: every field None except the
    stamps and, when the page could not be read, why."""
    d = {k: None for k in FIELDS}
    d["read_at"] = read_at
    d["asked_at"] = asked_at
    d["error"] = error
    return d


def failed(why, asked_at=None, read_at=None):
    """The answer for a page that could not be read: the reason in the
    Mac's own words, the rest null, so the screen can say "Motorway login
    needed" rather than show blank boxes."""
    return empty(asked_at=asked_at, read_at=read_at or now_stamp(), error=str(why)[:300])


def now_stamp():
    """The Mac's clock, the same shape as the rest of the push."""
    return datetime.datetime.now().replace(microsecond=0).isoformat()


# ---------------------------------------------------------------------------
# Small pure helpers

def iso_date(text):
    """A date the way a listing writes it, as YYYY-MM-DD, or None. Reads
    01/03/2027, 1 Mar 2027, 01 March 2027, 2027-03-01 and 2027-03-01T...
    Never a guess: anything else is None."""
    s = str(text or "").strip()
    if not s:
        return None
    m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})(?:[T ].*)?", s)
    if m:
        return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
    m = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(\d{4})", s)
    if m:
        return f"{m.group(3)}-{int(m.group(2)):02d}-{int(m.group(1)):02d}"
    m = re.fullmatch(r"(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3,9})\.?\s+(\d{4})", s)
    if m and m.group(2)[:3].lower() in _MONTHS:
        return f"{m.group(3)}-{_MONTHS[m.group(2)[:3].lower()]:02d}-{int(m.group(1)):02d}"
    m = re.fullmatch(r"([A-Za-z]{3,9})\.?\s+(\d{4})", s)
    if m and m.group(1)[:3].lower() in _MONTHS:
        return f"{m.group(2)}-{_MONTHS[m.group(1)[:3].lower()]:02d}-01"
    return None


def date_in(text):
    """The first date written anywhere in a line, as YYYY-MM-DD, or None."""
    s = str(text or "")
    for pat in (r"\d{4}-\d{2}-\d{2}", r"\d{1,2}/\d{1,2}/\d{4}",
                r"\d{1,2}(?:st|nd|rd|th)?\s+[A-Za-z]{3,9}\.?\s+\d{4}"):
        m = re.search(pat, s)
        if m:
            got = iso_date(m.group(0))
            if got:
                return got
    return None


def int_in(text):
    """The first whole number in a line (a mileage, a count), or None."""
    m = re.search(r"\d[\d,]*", str(text or ""))
    if not m:
        return None
    try:
        return int(m.group(0).replace(",", ""))
    except ValueError:
        return None


def mileage_in(text):
    """A mileage written in a line ("41,200 miles", "at 41200 mi"), or
    None. Never a bare number, which could be a day or a year."""
    m = re.search(r"(\d[\d,]*)\s*(?:miles|mi\b|mls)", str(text or ""), re.I)
    if not m:
        return None
    try:
        return int(m.group(1).replace(",", ""))
    except ValueError:
        return None


def money_in(text):
    m = MONEY.search(str(text or ""))
    if not m:
        return None
    try:
        return int(m.group(1).replace(",", ""))
    except ValueError:
        return None


def yes_no(text):
    """True for a yes, False for a no or a clear, None when the words say
    neither."""
    s = str(text or "").strip().lower()
    if not s:
        return None
    if s in ("yes", "y", "true") or s.startswith("yes"):
        return True
    if s in ("no", "n", "false", "none", "clear", "not recorded", "no record") or s.startswith("no ") or s.startswith("not "):
        return False
    return None


def modifications_text(flag, desc):
    """The seller's answer to "has the car been modified", in the words the
    shortlist car card shows (Steven, 2026-09-22, the tenth fact on the
    card). A No with nothing said is "None declared"; a description is
    given as written, one line, whatever the flag said beside it; a Yes
    with no description says so rather than pretending to know what was
    done. Neither on the page is None, never "None declared" (golden
    rule 4: a page that did not say is not a page that said no)."""
    text = None
    if isinstance(desc, list):
        desc = ", ".join(x.strip() for x in desc if isinstance(x, str) and x.strip()) or None
    if isinstance(desc, str) and desc.strip():
        text = ", ".join(x.strip() for x in desc.splitlines() if x.strip())
        if yes_no(text) is False and len(text.split()) <= 3:
            text = None
            flag = False if flag is None else flag
        elif yes_no(text) is True and len(text.split()) <= 1:
            text = None
            flag = True if flag is None else flag
    if text:
        return text
    yn = flag if isinstance(flag, bool) else yes_no(flag)
    if yn is False:
        return "None declared"
    if yn is True:
        return "Modified, the seller gave no details"
    return None


def lines_of(text):
    return [l.strip() for l in str(text or "").split("\n") if l.strip()]


def value_after(lines, *labels, within=1):
    """The line after the first line that IS one of the labels (case
    insensitive, a trailing colon ignored), looking up to `within` lines
    down for a non empty one. None when no label is on the page."""
    want = {l.lower().rstrip(":") for l in labels}
    for i, l in enumerate(lines):
        if l.lower().rstrip(":") in want:
            for j in range(i + 1, min(i + 1 + within, len(lines))):
                if lines[j]:
                    return lines[j]
            return None
    return None


def section(lines, heading, stop_headings=(), limit=40):
    """The lines under a heading, up to the next heading in stop_headings
    or `limit` lines. Empty when the heading is not on the page."""
    stops = {h.lower() for h in stop_headings}
    for i, l in enumerate(lines):
        if l.lower().rstrip(":") == heading.lower():
            out = []
            for j in range(i + 1, min(i + 1 + limit, len(lines))):
                if lines[j].lower().rstrip(":") in stops:
                    break
                out.append(lines[j])
            return out
    return []


def _clean(d):
    """Only the contract's keys, in its order, every one present."""
    out = {k: d.get(k) for k in FIELDS}
    if out["service"] is not None:
        s = dict(out["service"])
        out["service"] = {"stamps": s.get("stamps"), "last_date": s.get("last_date"),
                          "last_mileage": s.get("last_mileage"), "detail": s.get("detail")}
    if out["history_check"] is not None:
        h = dict(out["history_check"])
        out["history_check"] = {"finance": h.get("finance"), "written_off": h.get("written_off"),
                                "stolen": h.get("stolen"), "imported": h.get("imported")}
    return out


# ---------------------------------------------------------------------------
# Motorway: the page's embedded data, then its words

def _walk(obj, path=()):
    """Every (path, value) pair in a nested dict or list, leaves only."""
    if isinstance(obj, dict):
        for k, v in obj.items():
            yield from _walk(v, path + (str(k),))
    elif isinstance(obj, list):
        for i, v in enumerate(obj[:60]):
            yield from _walk(v, path + (f"[{i}]",))
    else:
        yield path, obj


def _find_first(obj, key):
    if isinstance(obj, dict):
        if key in obj:
            return obj[key]
        for v in obj.values():
            found = _find_first(v, key)
            if found is not None:
                return found
    elif isinstance(obj, list):
        for v in obj:
            found = _find_first(v, key)
            if found is not None:
                return found
    return None


def _first_of(data, *keys):
    for k in keys:
        v = _find_first(data, k)
        if v not in (None, "", [], {}):
            return v
    return None


def _num(v):
    if isinstance(v, bool):
        return None
    if isinstance(v, (int, float)):
        return v
    if isinstance(v, str):
        return int_in(v)
    if isinstance(v, dict):
        for k in ("value", "amount", "count", "price"):
            if k in v:
                return _num(v[k])
    return None


def _str(v):
    if v is None or isinstance(v, (bool, list, dict)):
        return None
    s = str(v).strip()
    return s or None


_IMG = re.compile(r"https://[A-Za-z0-9._\-]*imgix\.net/[A-Za-z0-9._/\-]*?\.jpe?g")
_MW_GALLERY = re.compile(r"https://motorway-photos[A-Za-z0-9.\-]*\.imgix\.net/[A-Za-z0-9._/\-]*?\.jpe?g")


def motorway_photos(text):
    """Every listing photo in Motorway's page data, one URL per photo, at
    a size the screen can show large. The motorway-photos host family only
    (never the seller's documents host), as motorway.py already does."""
    seen = {}
    for m in _MW_GALLERY.finditer(str(text or "")):
        seen.setdefault(m.group(0), m.group(0) + "?w=1400&auto=format,compress")
    return list(seen.values())


def _photo_in(obj):
    m = _IMG.search(json.dumps(obj))
    return m.group(0) if m else None


def _damage_from(obj):
    """Declared damage entries anywhere under a condition block: any dict
    carrying a description string, with the first photo beside it."""
    out = []

    def walk(o):
        if isinstance(o, dict):
            text = None
            for k in ("description", "desc", "text", "label", "title", "damageType", "type"):
                if isinstance(o.get(k), str) and o[k].strip():
                    text = o[k].strip()
                    break
            has_pic = any(isinstance(v, (str, list, dict)) and _IMG.search(json.dumps(v)) for v in o.values())
            if text and (has_pic or any(k in o for k in ("photos", "images", "image", "photo", "url"))):
                out.append({"text": text, "photo_url": _photo_in(o)})
                return
            for v in o.values():
                walk(v)
        elif isinstance(o, list):
            for v in o[:60]:
                walk(v)
    walk(obj)
    return out


def motorway_vehicle(data):
    """The car's own block in Motorway's page data, props.pageProps.vehicle
    (seen live 2026-09-17: mainInfo, details, ratings, serviceHistory,
    bidding, gallery, location under it). None when the page carried no
    car: a sale that has closed, a withdrawn listing, or a shell page."""
    if not isinstance(data, dict):
        return None
    props = data.get("props") if isinstance(data.get("props"), dict) else {}
    pp = props.get("pageProps") if isinstance(props.get("pageProps"), dict) else {}
    veh = pp.get("vehicle")
    if isinstance(veh, dict) and any(k in veh for k in ("mainInfo", "details", "bidding", "gallery", "ratings")):
        return veh
    return None


_MW_CONDITION = (
    ("hasDents", "dentsDesc", "Dents"),
    ("hasScratches", "scratchesDesc", "Scratches"),
    ("hasPaintProblems", "paintProblemsDesc", "Paint problems"),
    ("hasMissingParts", "missingPartsDesc", "Missing parts"),
    ("hasWindscreenProblems", "windscreenProblemsDesc", "Windscreen"),
    ("hasBeenSmokedIn", None, "Smoked in"),
)
# Warning lights read as their own field (Steven, 2026-09-18: "tyres,
# warning lights and mechanical are still missing"), not folded into the
# damage list, so the screen can give it its own line rather than burying
# it in a bullet of bodywork faults it has nothing to do with.


def motorway_warning_lights(cond):
    """Motorway's own warning-light flag off conditionAndDamage: a plain
    Yes/No with a description beside it. Null on a No or a missing block,
    never invented. Pure."""
    if not isinstance(cond, dict):
        return None
    if yes_no(cond.get("hasWarningLights")) is not True:
        return None
    desc = _str(cond.get("warningLightsDesc"))
    if desc:
        desc = ", ".join(x.strip() for x in desc.splitlines() if x.strip())
    return desc or "Warning light on the dashboard"


def motorway_modifications(data, lines=()):
    """Whether the seller declared any modifications, off the car block
    first, the page's words second. Motorway asks every seller the
    question, but the name it stores the answer under has not been seen
    live yet (2026-09-22), so this tries the names it would plausibly use
    and motorway_key_notes writes every "modif" path to deep_read.log on
    the first read, the same habit as every other field here. A name
    that is not there gives None, never a guess. Pure, tested."""
    flag = _first_of(data, "hasModifications", "hasBeenModified", "isModified", "hasModifiedParts", "vehicleModified")
    desc = _first_of(data, "modificationsDesc", "modificationDesc", "modificationsDescription", "modificationDetails",
                     "modificationsDetails", "modificationsText")
    plain = _first_of(data, "modifications", "modification")
    if isinstance(plain, bool) and flag is None:
        flag = plain
    elif isinstance(plain, (str, list)) and desc in (None, "", []):
        desc = plain
    elif isinstance(plain, dict):
        if flag is None:
            flag = _first_of(plain, "has", "value", "answer", "declared")
        if desc in (None, "", []):
            desc = _first_of(plain, "description", "desc", "details", "text")
    got = modifications_text(flag, desc)
    if got is None and lines:
        got = modifications_text(None, value_after(lines, "Modifications", "Modified", "Has the car been modified",
                                                   "Has this car been modified", "Vehicle modifications"))
    return got


def motorway_gallery(gal):
    """Motorway's gallery block (live 2026-09-17): gallery.images[] each
    with url, category (vehicle, wheels, tyres, damage, additionalImage,
    documents), kind, title, isDamaged and damageMeta. Every photo of the
    car at a size the screen can show large, the seller's documents left
    out; the damage photos separately, each with its title, so the screen
    can mark them. ([], []) when the block is not there. Pure, tested."""
    photos, damage = [], []
    if not isinstance(gal, dict) or not isinstance(gal.get("images"), list):
        return photos, damage
    seen = set()
    for im in gal["images"]:
        if not isinstance(im, dict):
            continue
        url = _str(im.get("url")) or _str((im.get("presets") or {}).get("desktop") if isinstance(im.get("presets"), dict) else None)
        if not url:
            continue
        url = url.split("?")[0]
        cat = str(im.get("category") or "").lower()
        if cat == "documents" or "document" in url.lower():
            continue
        if url in seen:
            continue
        seen.add(url)
        big = url + "?w=1400&auto=format,compress"
        photos.append(big)
        # A damage photo is one Motorway filed under damage, or one it
        # flagged as damaged with no category to say what of. Wheels and
        # tyres are excluded however damaged they are: they are not
        # bodywork, they have their own line, and counting them here was
        # showing a car with four scuffed alloys as four "areas of
        # damage" (Steven, 2026-09-18; still leaking in through the
        # gallery until 2026-09-19).
        if cat == "damage" or (cat not in ("wheels", "tyres") and im.get("isDamaged") is True):
            title = _str(im.get("title")) or "Damage, photographed"
            damage.append({"text": title, "photo_url": big,
                           "name": f"{im.get('kind') or ''} {title}".lower()})
    return photos, damage


NO_WHEEL_PROBLEMS = "No problems declared"

_MW_WHEELS = (
    ("hasTyreProblems", "tyreProblemsDesc", "Tyre problems"),
    ("hasScuffedAlloy", "scuffedAlloyDesc", "Scuffed alloys"),
)


def motorway_wheels(w):
    """Motorway's wheelsAndTyres block (live 2026-09-17): tyre problems and
    scuffed alloys as Yes/No with a count or description, plus whether the
    locking wheel nut and the tools are there. No tread depths anywhere on
    the page, so tyres_mm stays null; these lines go into tyre_notes
    instead of the damage list, wheels and tyres being their own thing,
    not bodywork. A Yes becomes an entry; a missing locking wheel nut or
    tools too. Pure, tested."""
    out = []
    for has_key, desc_key, label in _MW_WHEELS:
        if yes_no(w.get(has_key)) is not True:
            continue
        desc = _str(w.get(desc_key))
        if desc and desc.isdigit():
            n = int(desc)
            desc = f"{n} wheel{'s' if n != 1 else ''}" if label.startswith("Scuffed") else f"{n} tyre{'s' if n != 1 else ''}"
        out.append({"text": f"{label}: {desc}" if desc else label, "photo_url": None})
    if yes_no(w.get("hasLockingWheelNut")) is False:
        out.append({"text": "No locking wheel nut", "photo_url": None})
    if yes_no(w.get("hasToolsInBoot")) is False:
        out.append({"text": "No tools in the boot", "photo_url": None})
    return out


# Motorway tells you about a damage twice and in two places: the words
# are in conditionAndDamage ("Dents", "1 small (0-5cm)"), the photo is in
# the gallery, as an image whose kind names the same fault
# (kind "damage_dents", title "Dents"). The two have to be put back
# together or the screen shows one fault as two, the words with no photo
# and the photo with no words.
#
# Read live 2026-09-19 off three cars on the account's own shortlist: the
# number of lines in a description matched the number of photos of that
# fault every time (1 dent line to 1 dent photo, 2 scratch lines to 2
# scratch photos, 1 windscreen line to 1 windscreen photo), so line i is
# photo i. The token is matched against the image's kind AND its title,
# never against an exact name, because only scratches, dents and
# windscreen have been seen live: a paint or missing parts photo will
# carry the word either way, and one that matches nothing is still kept.
_MW_DAMAGE_TOKEN = {
    "hasDents": "dent",
    "hasScratches": "scratch",
    "hasPaintProblems": "paint",
    "hasMissingParts": "missing",
    "hasWindscreenProblems": "windscreen",
}


def motorway_damage(cond, damage_photos=()):
    """Motorway's declared bodywork damage, each entry carrying what the
    seller said about it and the photo of that very fault. Wheels and
    tyres are not in here: they are their own field (Steven, 2026-09-18),
    and the gallery's wheel and tyre photos are not bodywork damage
    however damaged they are, so they stay in the photo list only. A
    photo of something the condition block never declared is still kept,
    under its own title, rather than thrown away. Pure, tested."""
    cond = cond if isinstance(cond, dict) else {}
    pics = [dict(p) for p in (damage_photos or [])]
    out = []
    for has_key, desc_key, label in _MW_CONDITION:
        if yes_no(cond.get(has_key)) is not True:
            continue
        token = _MW_DAMAGE_TOKEN.get(has_key)
        mine = [p for p in pics if token and not p.get("taken") and token in (p.get("name") or "")]
        raw = _str(cond.get(desc_key)) if desc_key else None
        lines = [x.strip() for x in raw.splitlines() if x.strip()] if raw else []
        for i in range(max(len(lines), len(mine), 1)):
            line = lines[i] if i < len(lines) else None
            pic = mine[i] if i < len(mine) else None
            if pic:
                pic["taken"] = True
            out.append({"text": f"{label}: {line}" if line else label,
                        "photo_url": pic["photo_url"] if pic else
                        (_photo_in(cond.get(desc_key)) if desc_key and isinstance(cond.get(desc_key), (dict, list)) else None)})
    for p in pics:
        if not p.get("taken"):
            out.append({"text": p.get("text") or "Damage, photographed", "photo_url": p.get("photo_url")})
    return out


def motorway_condition(cond):
    """The declared condition on its own, with no photos to go with it.
    motorway_damage is the whole answer; this is it without a gallery."""
    return motorway_damage(cond)


def from_motorway(data, blob_text="", page_text=""):
    """The deep block out of a Motorway car page: its __NEXT_DATA__ blob
    first (the names Motorway uses, seen live in data/logs/motorway_detail.log
    and deep_read.log), the page's own words second for anything the data
    did not carry. A page with no car block is a failed read, said plainly,
    never an empty "read" (2026-09-17: six of ten starred cars came back
    blank with no error, their sale had closed). Pure."""
    d = empty()
    veh = motorway_vehicle(data)
    if veh is None:
        return failed("Motorway's page for this car carried no car data. The sale may have closed or the listing been withdrawn; try again on a live listing.")
    data = veh
    lines = lines_of(page_text)

    # MOT
    mot = _first_of(data, "mot", "motDetails", "motHistory")
    exp = _first_of(data, "motExpiryDate", "motExpiry", "motDue", "motDueDate", "motExpires")
    if isinstance(exp, str) and not exp.strip():
        exp = None
    if exp is None and isinstance(mot, dict):
        exp = _first_of(mot, "expiryDate", "expiry", "expires", "dueDate")
    d["mot_expiry"] = iso_date(_str(exp)) or date_in(value_after(lines, "MOT expiry", "MOT due", "MOT expires", "MOT expiry date", "MOT"))
    res = _first_of(data, "motResult", "motStatus", "motTestResult")
    if res is None and isinstance(mot, dict):
        res = _first_of(mot, "result", "status", "testResult")
    if res is None and isinstance(mot, dict):
        res = mot.get("motStatus")
    d["mot_result"] = _str(res) or _str(value_after(lines, "MOT result", "MOT status"))
    # Motorway gives the last test's date, never an expiry (live 2026-09-17):
    # said as the test date, the expiry is left null, never worked out.
    tested = _first_of(data, "lastMotDate", "motTestDate", "lastTestDate")
    if tested is None and isinstance(mot, dict):
        tested = _first_of(mot, "lastMotDate", "testDate", "date")
    d["mot_tested"] = iso_date(_str(tested)) or date_in(value_after(lines, "Last MOT", "MOT tested", "Last MOT test"))
    adv = _first_of(data, "motAdvisories", "advisories", "advisoryNotices")
    if adv is None and isinstance(mot, dict):
        adv = _first_of(mot, "advisories", "advisoryNotices", "advisory")
    if isinstance(adv, list):
        items = []
        for a in adv:
            t = _str(a) if not isinstance(a, dict) else _str(_first_of(a, "text", "description", "notice", "advisory"))
            if t:
                items.append(t)
        d["advisories"] = items
    elif isinstance(mot, dict) and mot.get("advisoriesAvailable") is True and d["mot_result"]:
        d["advisories"] = []
    elif isinstance(adv, str) and adv.strip():
        d["advisories"] = [adv.strip()]

    # Service book. Motorway (live 2026-09-17): serviceHistory.{serviceRecord,
    # officialServiceStampsCount, independentServiceStampsCount,
    # serviceHistoryRecords.items[].{mileage, serviceCentre, ...}} and
    # ratings.serviceHistory ("partial", "full").
    sh_block = data.get("serviceHistory") if isinstance(data.get("serviceHistory"), dict) else None
    sh = _str((sh_block or {}).get("serviceRecord")) or _str(_first_of(data.get("ratings") or {}, "serviceHistory")) \
        or (_str(_first_of(data, "serviceHistory")) if sh_block is None else None)
    records = _first_of(data, "serviceHistoryRecords", "serviceRecords", "serviceHistoryEntries", "serviceEntries", "services")
    if isinstance(records, dict):
        records = records.get("items") if isinstance(records.get("items"), list) else _first_of(records, "records", "entries")
    detail, last_date, last_mileage = [], None, None
    if isinstance(records, list) and records:
        for r in records:
            if isinstance(r, dict):
                when = iso_date(_str(_first_of(r, "date", "serviceDate", "dateOfService", "servicedAt", "createdAt")))
                miles = _num(_first_of(r, "mileage", "odometer", "miles"))
                who = _str(_first_of(r, "serviceCentre", "dealer", "garage", "type", "serviceType", "provider", "description"))
                bits = [b for b in (when, f"{miles:,} miles" if isinstance(miles, int) else None, who) if b]
                if bits:
                    detail.append(", ".join(bits))
                if when and (last_date is None or when > last_date):
                    last_date, last_mileage = when, miles if isinstance(miles, int) else last_mileage
                elif when is None and isinstance(miles, int) and (last_mileage is None or miles > last_mileage):
                    last_mileage = miles
            elif _str(r):
                detail.append(_str(r))
    stamps = None
    if sh_block is not None:
        official = _num(sh_block.get("officialServiceStampsCount"))
        indep = _num(sh_block.get("independentServiceStampsCount"))
        if official is not None or indep is not None:
            stamps = int(official or 0) + int(indep or 0)
    if stamps is None:
        stamps = _num(_first_of(data, "numberOfServices", "serviceCount", "serviceStamps", "numberOfServiceStamps"))
    if stamps is None and isinstance(records, list) and records:
        stamps = len(records)
    if stamps is None and lines:
        stamps = int_in(value_after(lines, "Service stamps", "Number of services", "Services"))
    if last_date is None:
        last_date = iso_date(_str(_first_of(data, "lastServiceDate", "lastServicedDate", "lastService"))) \
            or date_in(value_after(lines, "Last service", "Last serviced", "Last service date"))
    if last_mileage is None:
        last_mileage = _num(_first_of(data, "lastServiceMileage", "lastServicedMileage"))
        if last_mileage is None:
            last_mileage = int_in(value_after(lines, "Last service mileage"))
    if isinstance(sh, str) and sh.strip():
        detail = [sh.strip().capitalize() + " service history"] + detail
    if any(x is not None for x in (stamps, last_date, last_mileage)) or detail:
        d["service"] = {"stamps": int(stamps) if isinstance(stamps, (int, float)) else None,
                        "last_date": last_date,
                        "last_mileage": int(last_mileage) if isinstance(last_mileage, (int, float)) else None,
                        "detail": detail or None}

    # Keepers: the current keeper's start, the count before.
    ks = _str(_first_of(data, "keeperStartDate", "currentKeeperSince", "keeperSince"))
    ks_iso = iso_date(ks) or date_in(value_after(lines, "Keeper start date", "Current keeper since", "Owned since"))
    keepers = []
    if ks_iso:
        keepers.append({"from": ks_iso, "to": None})
    kl = _first_of(data, "keepers", "keeperHistory", "previousKeepers")
    if isinstance(kl, list) and kl:
        starts = []
        for k in kl:
            if isinstance(k, dict):
                f = iso_date(_str(_first_of(k, "from", "startDate", "start", "dateFrom")))
                t = iso_date(_str(_first_of(k, "to", "endDate", "end", "dateTo")))
                starts.append((f, t))
        starts = [x for x in starts if x[0] or x[1]]
        if starts:
            # Motorway lists only each keeper's start; the one before ends
            # when the next begins, the last is the current keeper.
            keepers = []
            for i, (f, t) in enumerate(starts):
                nxt = starts[i + 1][0] if i + 1 < len(starts) else None
                keepers.append({"from": f, "to": t or nxt})
    d["keepers"] = keepers or None

    # Colour, keys
    spec = data.get("specification") if isinstance(data.get("specification"), dict) else {}
    d["colour"] = _str(spec.get("exterior")) or _str(_first_of(data, "colour", "color", "exteriorColour", "exteriorColor", "bodyColour")) \
        or _str(value_after(lines, "Colour", "Color", "Exterior colour"))
    d["interior"] = _str(spec.get("interior")) or _str(_first_of(data, "interiorColour", "interiorTrim", "upholstery"))
    keys = _num(_first_of(data, "numberOfKeys", "keys", "keyCount", "noOfKeys"))
    if keys is None:
        keys = int_in(value_after(lines, "Keys", "Number of keys", "Spare keys"))
    d["keys"] = int(keys) if isinstance(keys, (int, float)) and 0 <= keys <= 9 else None

    # Damage, from the condition block
    cond = _first_of(data, "conditionAndDamage", "damage", "damageReport", "condition", "bodyworkDamage")
    gallery_photos, damage_photos = motorway_gallery(data.get("gallery"))
    dmg = motorway_damage(cond, damage_photos) if isinstance(cond, dict) else list(damage_photos)
    if not dmg and cond is not None:
        dmg = _damage_from(cond)
    d["damage"] = [{"text": e["text"], "photo_url": e.get("photo_url")} for e in dmg] or None

    # Warning lights and tyres/wheels are their own lines, not bodywork
    # damage (Steven, 2026-09-18): a warning light or a worn tyre is a
    # different kind of problem to a scratched bumper.
    d["warning_lights"] = motorway_warning_lights(cond) if isinstance(cond, dict) else None
    d["modifications"] = motorway_modifications(data, lines)
    wheels = data.get("wheelsAndTyres") if isinstance(data.get("wheelsAndTyres"), dict) else None
    wheel_items = motorway_wheels(wheels) if wheels else []
    # A block that was there and said nothing wrong is an answer too
    # (Steven, 2026-09-25: every car read "Not read yet" under Wheels and
    # tyres), so it says so rather than leaving the line looking unread.
    d["tyre_notes"] = " · ".join(w["text"] for w in wheel_items) or (NO_WHEEL_PROBLEMS if wheels else None)

    # Tyres: any tread depth in mm under a tyres block, in the order found
    ty = _first_of(data, "tyres", "tyreTread", "tyreDepths", "tyreCondition", "wheelsAndTyres")
    mm = []
    if ty is not None:
        for path, v in _walk(ty):
            name = " ".join(path).lower()
            if any(w in name for w in ("tread", "depth", "mm")) and isinstance(v, (int, float)) and not isinstance(v, bool) and 0 <= v <= 12:
                mm.append(float(v) if isinstance(v, float) else int(v))
            elif isinstance(v, str) and re.fullmatch(r"\d+(\.\d+)?\s*mm", v.strip()):
                mm.append(float(v.strip().rstrip("m").strip()))
    d["tyres_mm"] = mm or None

    # The listing's own history check. Motorway (live 2026-09-17):
    # details.hpiHistoryCheck.{financeAgreementsCount, isMileageClocked, ...}
    # and details.onFinance ("Yes"/"No").
    hpi = _first_of(data, "hpiHistoryCheck", "historyCheck", "hpi")
    hpi = hpi if isinstance(hpi, dict) else {}
    fin_text = None
    n_fin = _num(hpi.get("financeAgreementsCount"))
    if n_fin is not None:
        fin_text = f"{int(n_fin)} finance agreement{'s' if n_fin != 1 else ''} recorded" if n_fin else "Clear"
    if fin_text is None:
        on_fin = _str(_first_of(data, "onFinance"))
        if on_fin:
            fin_text = "Outstanding" if yes_no(on_fin) else ("Clear" if yes_no(on_fin) is False else on_fin)
    if fin_text is None:
        fin = _first_of(data, "outstandingFinance", "financeStatus", "hasOutstandingFinance", "finance", "hpiFinance")
        if isinstance(fin, bool):
            fin_text = "Outstanding" if fin else "Clear"
        elif isinstance(fin, dict):
            v = _first_of(fin, "status", "outstanding", "hasFinance", "value")
            fin_text = ("Outstanding" if v else "Clear") if isinstance(v, bool) else _str(v)
        else:
            fin_text = _str(fin)
    if fin_text is None:
        fin_text = _str(value_after(lines, "Outstanding finance", "Finance"))
    wo_bits = [hpi.get(k) for k in ("hasBeenWrittenOffByDamage", "hasBeenWrittenOffByTheft", "hasBeenSalvaged") if k in hpi]
    if wo_bits:
        wo = any(v is True for v in wo_bits) if all(isinstance(v, bool) for v in wo_bits) else None
    else:
        wo = _first_of(hpi, "isWrittenOff", "isWriteOff", "writtenOff", "writeOffCategory", "isInsuranceWriteOff", "isCatWriteOff")
        if wo is None:
            wo = _first_of(data, "writtenOff", "isWrittenOff", "insuranceWriteOff", "writeOff", "writeOffCategory")
    st = hpi.get("hasBeenStolen") if "hasBeenStolen" in hpi else _first_of(hpi, "isStolen", "stolen", "isReportedStolen")
    if st is None:
        st = _first_of(data, "stolen", "isStolen", "stolenStatus")
    im = hpi.get("hasBeenImported") if "hasBeenImported" in hpi else _first_of(hpi, "isImported", "imported", "isImport")
    if im is None:
        im = _first_of(data, "imported", "isImported", "importStatus")
    clocked = hpi.get("isMileageClocked")
    if d["mot_expiry"] is None:
        d["mot_expiry"] = iso_date(_str(hpi.get("motExpiryDate")))

    def flag(v, label):
        if isinstance(v, bool):
            return v
        if isinstance(v, str):
            s = v.strip().lower()
            if s in ("none", "no", "clear", "false", ""):
                return False
            if s in ("yes", "true") or s.startswith("cat"):
                return True
            return None
        if v is None and lines:
            return yes_no(value_after(lines, label))
        return None
    hc = {"finance": fin_text, "written_off": flag(wo, "Written off"), "stolen": flag(st, "Stolen"),
          "imported": flag(im, "Imported")}
    if clocked is True and hc["finance"]:
        hc["finance"] += ", mileage clocked"
    d["history_check"] = hc if any(v is not None for v in hc.values()) else None

    # Every photo, large: the gallery block when the page has one, else
    # every motorway photos link in the blob.
    photos = gallery_photos or motorway_photos(blob_text)
    d["photos"] = photos or None

    # Reserve and bid as the page shows them now
    bidding = _first_of(data, "bidding", "auction")
    res_now = _first_of(data, "reservePrice", "reserve", "priceReserve", "minimumPrice")
    if res_now is None and isinstance(bidding, dict):
        res_now = _first_of(bidding, "reservePrice", "reserve")
    d["reserve_now"] = _num(res_now) if res_now is not None else money_in(value_after(lines, "Reserve price", "Reserve"))
    bid = _first_of(data, "currentBid", "highestBid", "currentHighestBid", "topBid")
    if bid is None and isinstance(bidding, dict):
        bid = _first_of(bidding, "currentBid", "highestBid", "amount")
    d["bid_now"] = _num(bid) if bid is not None else money_in(value_after(lines, "Current bid", "Highest bid"))
    return _clean(d)


def log_notes(platform, url, notes):
    """Append one read's key notes to data/logs/deep_read.log, best
    effort: the log must never break the read it describes."""
    import os
    try:
        root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
        logs = os.path.join(root, "data", "logs")
        os.makedirs(logs, exist_ok=True)
        with open(os.path.join(logs, "deep_read.log"), "a", encoding="utf-8") as f:
            f.write(f"{now_stamp()} {platform} {url}\n")
            for n in notes or []:
                f.write(f"  {n}\n")
    except Exception:
        pass


def motorway_key_notes(data, limit=80):
    """Every path in the page data whose name is about the things the
    deep read wants, with a short value, for data/logs/deep_read.log: the
    names Motorway really uses are on record after the first live read,
    so the next fix needs no guessing (the same habit as motorway.py's
    _detail_keys_once)."""
    first = ("mot", "tyre", "colour", "color", "paint", "advis", "spec", "main")
    later = ("keeper", "key", "damage", "hpi", "stolen", "written", "import", "condition", "date", "gallery", "wheel", "modif")
    head, tail = [], []
    for path, v in _walk(motorway_vehicle(data) or data):
        name = ".".join(path)
        low = name.lower()
        if low.startswith("bidding.") or low.startswith("location.") or low.startswith("seller."):
            continue
        # One image's worth of the gallery is enough to see its shape.
        if low.startswith("gallery.images.[") and not low.startswith("gallery.images.[0]"):
            continue
        line = f"{name} = {json.dumps(v)[:120]}"
        if any(w in low for w in first):
            head.append(line)
        elif any(w in low for w in later):
            tail.append(line)
    return (head + tail)[:limit]


# ---------------------------------------------------------------------------
# Carwow: the page's visible words and its HTML for the gallery

_CW_GALLERY = re.compile(r"https://carwow-sell-my-car\.imgix\.net/[A-Za-z0-9]+")
_CW_BIG = "?w=1400&auto=compress,format&q=70&cs=srgb&fit=max"


def _cw_big(url):
    """One Carwow photo at a size the screen can show large. Carwow hangs
    its own sizing on the asset key and sometimes twice over (the live
    page carries "...?ixlib=...&q=80?ar=3:2&fit=crop"), so everything
    after the first question mark goes and ours replaces it. That also
    makes the same photo one string wherever it was found, so a damage
    photo matches its entry in the gallery list. Pure."""
    m = _CW_GALLERY.search(str(url or ""))
    return (m.group(0) + _CW_BIG) if m else None


def carwow_photos(html):
    seen = {}
    for m in _CW_GALLERY.finditer(str(html or "")):
        seen.setdefault(m.group(0), m.group(0) + _CW_BIG)
    return list(seen.values())


# Carwow's condition report, read live off a dealer listing page on
# 2026-09-19 (listing 14611801, DN17NWS). Each damage is its own block
# carrying where it is, what it is and the photo of it, so the photo
# never has to be guessed:
#
#   .listing__vehicle-condition-damage-item__detail
#     .listing__vehicle-condition-damage-item__information
#       <strong>Rear driver alloy</strong>            where
#       .listing__..-damage-item__description         what: "Scuffed alloy"
#     label.listing__..-report-item-damage-label
#       <img>                                         the photo of it
#
# The group heading above the blocks (Scratches, Scuffs, Dents, Chips,
# Alloys) is the nearest .listing__..-report-item-header-title before
# them; a group with nothing wrong says "No damage reported" and carries
# no blocks at all, so it contributes nothing.
#
# The page also holds a #damage-gallery dialog with the same photos big.
# Do NOT read it by the data-gallery-opener-starting-index-value each
# block carries: it is a swiper that repeats its slides to loop (ten img
# tags for five damages), so going by position pairs the wrong photo
# with the wrong fault. The photo inside the block is the safe one.
_CW_DMG_ITEM = re.compile(
    r'listing__vehicle-condition-damage-item__detail(.*?)'
    r'(?=listing__vehicle-condition-damage-item__detail'
    r'|listing__vehicle-condition-report-item-header|$)', re.S)
_CW_DMG_TITLE = re.compile(r'listing__vehicle-condition-report-item-header-title"[^>]*>\s*([^<]+)')
_CW_DMG_WHERE = re.compile(r"<strong[^>]*>\s*([^<]+?)\s*</strong>", re.S)
_CW_DMG_WHAT = re.compile(r'listing__vehicle-condition-damage-item__description"[^>]*>\s*([^<]+?)\s*<', re.S)
_CW_DMG_PHOTO = re.compile(r'listing__vehicle-condition-report-item-damage-label.*?<img[^>]*?src="([^"]+)"', re.S)


def carwow_damage(html):
    """The declared damage on a Carwow listing page, each entry with the
    photo of that very damage beside it. [] when the page carried no
    condition report, which is the caller's cue to fall back to the
    page's visible words. Golden rule 4: a block whose words could not be
    read is left out rather than invented, and a block with no photo
    keeps a null photo_url rather than borrowing another one. Pure,
    tested against data/fixtures/carwow_condition_report_sample.html."""
    import html as htmllib
    text = str(html or "")
    if "listing__vehicle-condition-damage-item__detail" not in text:
        return []
    heads = [(m.start(), htmllib.unescape(m.group(1)).strip()) for m in _CW_DMG_TITLE.finditer(text)]
    out, seen = [], set()
    for m in _CW_DMG_ITEM.finditer(text):
        chunk = m.group(1)
        group = None
        for pos, title in heads:
            if pos < m.start():
                group = title
        where = _CW_DMG_WHERE.search(chunk)
        what = _CW_DMG_WHAT.search(chunk)
        where = htmllib.unescape(where.group(1)).strip() if where else None
        what = htmllib.unescape(what.group(1)).strip() if what else None
        if where and what:
            line = f"{where}: {what}"
        else:
            line = where or what or group
        if not line:
            continue
        photo = _CW_DMG_PHOTO.search(chunk)
        photo = _cw_big(htmllib.unescape(photo.group(1))) if photo else None
        if (line, photo) in seen:
            continue
        seen.add((line, photo))
        out.append({"text": line, "photo_url": photo})
    return out


def _carwow_count(text):
    """Carwow's "3 reported" as 3; "No damage", "No damage reported" and
    anything else as None. Pure."""
    m = re.fullmatch(r"(\d+)\s+reported", str(text or "").strip(), re.I)
    return int(m.group(1)) if m else None


_CW_OWNER = re.compile(r"^(current|\d+(?:st|nd|rd|th))\s+owner$", re.I)
_CW_SPAN = re.compile(r"^(.+?)\s*[\u2013\u2014-]\s*(.+)$")


def carwow_keepers(lines):
    """Every owner from the Ownership history section of a Carwow car page,
    oldest first, as {"from", "to"} with "to" None for the current owner.
    Seen live 2026-09-29: "Ownership history", then for each owner newest
    first a label ("Current owner", "2nd owner", "1st owner"), its dates
    ("07 Oct 2015 \u2013 06 Jan 2020", or "... \u2013 Present") and how long.
    Only the current keeper's start used to be read, so the car page listed
    one owner of four (SA18AJX, Steven). None when the section is not
    there or no owner's dates read. Pure."""
    try:
        start = next(i for i, l in enumerate(lines) if l.strip().lower() == "ownership history")
    except StopIteration:
        return None
    out = []
    i = start + 1
    while i + 1 < len(lines) and _CW_OWNER.match(lines[i].strip()):
        m = _CW_SPAN.match(lines[i + 1].strip())
        if not m:
            break
        f = date_in(m.group(1))
        t = None if m.group(2).strip().lower() == "present" else date_in(m.group(2))
        if f or t:
            out.append({"from": f, "to": t})
        i += 3 if i + 2 < len(lines) and not _CW_OWNER.match(lines[i + 2].strip()) else 2
    return list(reversed(out)) or None


def carwow_mot(lines):
    """The latest MOT off a Carwow car page, read live 2026-09-25 off
    listings 14696768 (PH58EEE) and 14677268 (BL18YCN). The MOT history
    panel opens with "Latest MOT", the test date, the result, then
    Mileage, MOT Test Number and "Expiry Date" as label then value, then
    "4 advisories" (or "1 advisory") followed by that many lines, then
    the older tests under "MOT history". The strip at the top of the page
    carries only "Last MOT", the date and "Pass with 4 advisories", used
    when the panel is not there. Returns (tested, result, expiry,
    advisories), each None when the page did not say. Pure."""
    low = [l.lower() for l in lines]
    tested = result = expiry = None
    advisories = None
    if "latest mot" in low:
        i = low.index("latest mot")
        block = []
        for l in lines[i + 1:i + 40]:
            if l.lower() in ("mot history", "view full history", "cap live"):
                break
            block.append(l)
        if block:
            tested = date_in(block[0])
        if len(block) > 1 and re.fullmatch(r"(pass|fail)(ed)?", block[1], re.I):
            result = block[1]
        expiry = date_in(value_after(block, "Expiry Date", "Expiry"))
        advisories = []
        for j, l in enumerate(block):
            m = re.fullmatch(r"(\d+) advisor(y|ies)", l, re.I)
            if m:
                advisories = block[j + 1:j + 1 + int(m.group(1))]
                break
    if tested is None and "last mot" in low:
        i = low.index("last mot")
        tested = date_in(lines[i + 1]) if i + 1 < len(lines) else None
        said = lines[i + 2] if i + 2 < len(lines) else ""
        m = re.match(r"(pass|fail)", said, re.I)
        if tested and m:
            result = m.group(1).capitalize()
            if re.search(r"no advisories", said, re.I):
                advisories = []
    return tested, result, expiry, advisories


def carwow_wheels(lines):
    """Carwow's "Wheels & Extras" panel, read live 2026-09-25: Tyre
    condition ("No damage" or "2 reported"), Alloy Condition, Spare
    wheel, Wheel toolkit and Locking wheel nut, each a label then a
    value. Carwow gives no tread depths, so like Motorway this is
    tyre_notes, not tyres_mm. A missing locking wheel nut or toolkit is
    a note; "No information" is not. "No problems declared" when the
    panel was there and clear, None when it was not on the page. Pure."""
    block = section(lines, "Wheels & Extras", stop_headings=("MotorCheck", "MOT history", "Latest MOT"), limit=16)
    if not block:
        return None
    notes = []
    tyres = _carwow_count(value_after(block, "Tyre condition"))
    if tyres:
        notes.append(f"Tyre problems: {tyres} tyre{'s' if tyres != 1 else ''}")
    alloys = _carwow_count(value_after(block, "Alloy Condition"))
    if alloys:
        notes.append(f"Alloy damage: {alloys} wheel{'s' if alloys != 1 else ''}")
    said_no = lambda label: str(value_after(block, label) or "").strip().lower() == "no"
    if said_no("Locking wheel nut"):
        notes.append("No locking wheel nut")
    if said_no("Wheel toolkit"):
        notes.append("No wheel toolkit")
    return " · ".join(notes) or NO_WHEEL_PROBLEMS


def from_carwow(page_text, html=""):
    """The deep block out of a Carwow car page's words. Carwow lays its
    facts out as a label line then a value line, the same shape
    carwow.enrich_from_detail already reads (Former keepers, Start of
    current keeper, Mechanical faults, the MotorCheck block). Pure."""
    d = empty()
    lines = lines_of(page_text)
    # The MOT as Carwow's page actually lays it out (live 2026-09-25): the
    # labels below it were guesses that never matched, so every Carwow car
    # read "Not read yet" under MOT history.
    tested, result, expiry, advs = carwow_mot(lines)
    d["mot_tested"] = tested
    d["mot_expiry"] = expiry or date_in(value_after(lines, "MOT expiry", "MOT due", "MOT expires", "MOT expiry date", "MOT valid until", "MOT"))
    d["mot_result"] = result or _str(value_after(lines, "MOT result", "MOT status", "Last MOT result"))
    adv = section(lines, "Advisories", stop_headings=("Service history", "MotorCheck", "Mechanical faults", "Damage", "Photos"), limit=15)
    adv = [a for a in adv if a.lower() not in ("none", "no advisories", "no advisories reported")]
    d["advisories"] = advs if advs is not None else (adv or ([] if value_after(lines, "Advisories") and value_after(lines, "Advisories").lower().startswith("no") else None))
    d["tyre_notes"] = carwow_wheels(lines)

    sh = _str(value_after(lines, "Service history"))
    stamps = int_in(value_after(lines, "Service stamps", "Number of services", "Services", "Number of stamps"))
    last = value_after(lines, "Last service", "Last serviced", "Last service date")
    detail = []
    if sh:
        detail.append(sh)
    for l in section(lines, "Service records", stop_headings=("MotorCheck", "Mechanical faults", "Damage"), limit=20):
        if date_in(l):
            detail.append(l)
    if stamps is None and len([l for l in detail if date_in(l)]) > 0:
        stamps = len([l for l in detail if date_in(l)])
    if sh or stamps is not None or last:
        d["service"] = {"stamps": stamps, "last_date": date_in(last), "last_mileage": mileage_in(last),
                        "detail": detail or None}

    ks = date_in(value_after(lines, "Start of current keeper", "Current keeper since"))
    d["keepers"] = carwow_keepers(lines) or ([{"from": ks, "to": None}] if ks else None)
    d["colour"] = _str(value_after(lines, "Colour", "Exterior colour", "Color"))
    keys = int_in(value_after(lines, "Keys", "Number of keys", "Spare keys"))
    d["keys"] = keys if keys is not None and 0 <= keys <= 9 else None

    dmg = carwow_damage(html)
    if not dmg:
        for l in section(lines, "Damage", stop_headings=("MotorCheck", "Service history", "Mechanical faults", "Photos", "Description"), limit=20):
            if l.lower() in ("none", "no damage", "no damage reported"):
                break
            dmg.append({"text": l, "photo_url": None})
    d["damage"] = dmg or None

    mm = []
    for l in section(lines, "Tyres", stop_headings=("MotorCheck", "Service history", "Damage"), limit=12):
        for m in re.finditer(r"(\d+(?:\.\d+)?)\s*mm", l):
            mm.append(float(m.group(1)) if "." in m.group(1) else int(m.group(1)))
    d["tyres_mm"] = mm or None

    fin = _str(value_after(lines, "Outstanding finance", "Finance", "Finance check"))
    hc = {"finance": fin, "written_off": yes_no(value_after(lines, "Written off", "Write off", "Insurance write off")),
          "stolen": yes_no(value_after(lines, "Stolen", "Stolen check")),
          "imported": yes_no(value_after(lines, "Imported", "Import", "Import check"))}
    d["history_check"] = hc if any(v is not None for v in hc.values()) else None
    d["modifications"] = modifications_text(None, value_after(lines, "Modifications", "Modified", "Vehicle modifications",
                                                              "Has the car been modified", "Has this car been modified"))

    photos = carwow_photos(html)
    d["photos"] = photos or None
    d["reserve_now"] = money_in(value_after(lines, "Reserve price", "Reserve")) or money_in(value_after(lines, "CAP clean"))
    d["bid_now"] = money_in(value_after(lines, "Current bid", "Highest bid", "Leading bid"))
    return _clean(d)


# ---------------------------------------------------------------------------
# DealerWay: the page's visible words (data/fixtures/dealerway_detail_sample.txt)

def from_dealerway(page_text):
    d = empty()
    lines = lines_of(page_text)
    mot = section(lines, "MOT", stop_headings=("Service History", "Bodywork Issues"), limit=8)
    d["mot_result"] = _str(value_after(mot, "Status"))
    d["mot_expiry"] = date_in(value_after(mot, "Expiry Date", "Expiry"))
    adv = value_after(mot, "Advisories")
    if adv is not None:
        d["advisories"] = [] if adv.lower().startswith("no") else [adv]
    rec = _str(value_after(lines, "Service Record"))
    main = int_in(value_after(lines, "Main Dealer Services"))
    indep = int_in(value_after(lines, "Independent Dealer Services"))
    if rec or main is not None or indep is not None:
        stamps = (main or 0) + (indep or 0) if (main is not None or indep is not None) else None
        detail = [x for x in (rec, f"{main} main dealer" if main is not None else None,
                              f"{indep} independent" if indep is not None else None) if x]
        d["service"] = {"stamps": stamps, "last_date": None, "last_mileage": None, "detail": detail or None}
    d["colour"] = _str(value_after(lines, "Exterior Colour", "Colour"))
    keys = int_in(value_after(lines, "Number of Keys"))
    d["keys"] = keys if keys is not None and 0 <= keys <= 9 else None
    body = value_after(lines, "Bodywork Issues")
    if body and not body.lower().startswith("no bodywork"):
        d["damage"] = [{"text": body, "photo_url": None}]
    first = date_in(value_after(lines, "First Registered"))
    d["keepers"] = [{"from": first, "to": None}] if first and int_in(value_after(lines, "Number of Owners")) == 1 else None
    d["reserve_now"] = money_in(value_after(lines, "Reserve price", "Reserve"))
    d["bid_now"] = money_in(value_after(lines, "Current Bid", "Current bid", "Highest Bid"))
    return _clean(d)


# ---------------------------------------------------------------------------
# Auction4Cars: the detail page's HTML

_A4C_GALLERY = re.compile(r"https://www\.auction4cars\.com/AuctionImages/\d+/Image_\d+\.jpg")
_A4C_RESERVE = re.compile(r"priceCon\.data\('reserve',\s*([\d.]+)\)")


def from_auction4cars(html):
    """What an Auction4Cars car page shows: its Vehicle Details block, the
    service history table, the real reserve in its inline script (the
    same reads auction4cars.py already makes) and every photo."""
    import html as htmllib
    d = empty()
    html = str(html or "")
    fields = {}
    for m in re.finditer(r'<div class="car-detail-title">([^<]*)</div>\s*<div class="car-detail-content">\s*([^<]*?)\s*</div>', html):
        label = htmllib.unescape(m.group(1)).replace("\xa0", " ").strip().rstrip(":").strip()
        fields[label] = htmllib.unescape(m.group(2)).strip()
    d["colour"] = _str(fields.get("Colour") or fields.get("Color"))
    d["mot_expiry"] = iso_date(fields.get("MOT Expiry") or fields.get("MOT")) or date_in(fields.get("MOT Expiry") or fields.get("MOT"))
    keys = int_in(fields.get("Keys") or fields.get("Number of Keys"))
    d["keys"] = keys if keys is not None and 0 <= keys <= 9 else None
    rows = re.findall(r"<td>\s*([\d/]+)\s*</td>\s*<td>\s*([\d,]*)\s*</td>\s*<td>\s*([^<]*?)\s*</td>", html)
    detail, last_date, last_mileage = [], None, None
    for when, miles, kind in rows:
        iso = iso_date(when)
        m = int_in(miles)
        detail.append(", ".join(x for x in (iso or when, f"{m:,} miles" if m else None, kind.strip() or None) if x))
        if iso and (last_date is None or iso > last_date):
            last_date, last_mileage = iso, m
    if rows:
        d["service"] = {"stamps": len(rows), "last_date": last_date, "last_mileage": last_mileage, "detail": detail}
    seen = {}
    for m in _A4C_GALLERY.finditer(html):
        seen.setdefault(m.group(0), m.group(0))
    d["photos"] = list(seen.values()) or None
    m = _A4C_RESERVE.search(html)
    if m:
        try:
            d["reserve_now"] = round(float(m.group(1)))
        except ValueError:
            pass
    return _clean(d)
