"""
The BidBrain buying brain. Section 3 of the build brief.

This module holds the signed off logic and nothing else. It does not read the
web, it does not touch the database, it just takes a car and decides three
things:

  1. Is the car allowed at all (the bans and the hard gate).
  2. If it is allowed, what is the most Steven should pay (the pricing rule).
  3. What should be flagged on the card (service history, VAT qualifying).

Golden rules honoured here:
  . When the engine cannot be confirmed, the car is excluded. Play safe.
  . A car is never priced on a guessed valuation. If exactly one of Glass's or
    Cazana reads, the car is priced on that real value and the missing one is
    flagged. Only when BOTH are missing is the car held back.
  . Nothing here ever places a bid. It only recommends.

All money is handled in whole pounds.
"""

from dataclasses import dataclass, field
from typing import Optional
import datetime
import re


# The flat spread that sits on every car, whatever the price. It covers prep,
# buyer fees, VAT on the margin, transport and profit.
FLAT_SPREAD = 3000

# The uplift added to each raw retail valuation before it governs the bid.
RETAIL_UPLIFT = 0.15

# The threshold that decides which calculated value governs.
GOVERNING_THRESHOLD = 10000

# The hard gate limits.
MAX_MILEAGE = 90000
MAX_RESERVE = 11000
MIN_AGE = 2
MAX_AGE = 10
MAX_DISTANCE_MILES = 210
ALLOWED_GRADES = {1, 2, 3}
MAX_OWNERS = 4

# Makes banned outright, no matter how good the rest looks.
BANNED_MAKES = {"land rover", "volvo", "jeep", "jaguar", "mg", "mitsubishi", "infiniti"}

# Van and commercial model names, excluded as not cars. Horizon and Independence
# are wheelchair access conversions, which Steven treats as vans.
VAN_MODELS = ["transit", "connect", "partner", "berlingo", "caddy", "combo",
              "kangoo", "doblo", "expert", "vivaro", "traffic", "transporter",
              "scudo", "proace", "nv200", "rifter", "horizon", "independence"]

# Specific models excluded outright, regardless of price. (make, model token).
BANNED_MODELS = [("suzuki", "swift"), ("fiat", "500")]

# Value caps. Steven will not buy these when CAP clean is over the limit, with
# optional exception models (MINI is fine over the cap if a Countryman or
# Clubman). make is required, model token optional (None means any model).
VALUE_CAPS = [
    {"make": "ford", "model": "fiesta", "cap": 7000},
    {"make": "citroen", "model": None, "cap": 7000},
    {"make": "seat", "model": "ibiza", "cap": 7000},
    {"make": "kia", "model": "stonic", "cap": 7000},
    {"make": "mazda", "model": None, "cap": 7000},
    {"make": "mini", "model": None, "cap": 7000, "except": ["countryman", "clubman"]},
]

# Small city cars. Steven does not buy these when CAP clean is over 5,000
# pounds (too much money in a small car). Below that they are fine. Model name
# tokens, with Fiat 500 and Abarth 595 handled by make.
SMALL_CAR_TOKENS = {"aygo", "108", "c1", "up!", "up", "citigo", "mii", "i10",
                    "picanto", "panda", "twingo", "fortwo", "forfour", "celerio",
                    "alto", "ignis", "adam", "viva", "mirage", "mg3", "sandero",
                    "spark", "ka+", "ka"}
SMALL_CAR_CAP_LIMIT = 5000


def is_small_city_car(car) -> bool:
    make = _norm(car.make)
    model = _norm(car.model)
    if make in ("fiat", "abarth"):
        toks0 = model.replace("+", " ").split()
        if "500" in toks0 or "595" in toks0 or model.startswith("500") or model.startswith("595"):
            return True
    toks = set(model.replace("+", " ").split())
    return bool(toks & SMALL_CAR_TOKENS)


def _norm(text: Optional[str]) -> str:
    """Lowercase, collapse spaces. Keeps dots so 1.5 stays 1.5."""
    if text is None:
        return ""
    return re.sub(r"\s+", " ", str(text).strip().lower())


# Each banned engine rule lists the tokens that must all appear in the engine
# text. Some rules only bite for certain makes or model years. The notes are
# plain English so the reason shown on screen reads cleanly.
#
# The broad 2.0 TFSI and 1.8 TFSI rules are deliberate. Section 3.3 says the
# uncertainty rule should drop most older Audi and VW TFSI petrol turbos, and
# that is intended.
BANNED_ENGINES = [
    {"name": "1.2 PureTech",
     "all": ["1.2", "puretech"]},

    {"name": "Ford 1.0 EcoBoost wet belt, 2014 to 2019",
     "all": ["1.0", "ecoboost"], "makes": ["ford"], "year_from": 2014, "year_to": 2019},

    {"name": "Ford 1.5 EcoBoost",
     "all": ["1.5", "ecoboost"], "makes": ["ford"]},

    {"name": "Ford 2.0 EcoBoost, pre 2019",
     "all": ["2.0", "ecoboost"], "makes": ["ford"], "year_to": 2018},

    {"name": "VW and Audi 1.5 TSI, EA211 evo",
     "all": ["1.5", "tsi"]},

    {"name": "Hyundai and Kia 1.6 Gamma or Nu GDI",
     "all": ["1.6", "gdi"], "makes": ["hyundai", "kia"]},
    {"name": "Hyundai and Kia 1.6 Gamma or Nu GDI",
     "any_token": ["gamma", "nu"], "all": ["1.6"], "makes": ["hyundai", "kia"]},

    {"name": "Nissan 1.2 DIG-T, HR12DDR",
     "all": ["1.2", "dig"], "makes": ["nissan"]},
    {"name": "Nissan 1.2 DIG-T, HR12DDR",
     "all": ["hr12ddr"]},

    {"name": "Renault and Nissan 0.9 TCe",
     "all": ["0.9", "tce"]},

    {"name": "Renault 1.2 TCe, H5Ft",
     "all": ["1.2", "tce"]},
    {"name": "Renault 1.2 TCe, H5Ft",
     "all": ["h5ft"]},

    {"name": "Vauxhall and Opel 1.4 Turbo, A14NET or B14NET",
     "all": ["1.4", "turbo"], "makes": ["vauxhall", "opel"]},
    {"name": "Vauxhall and Opel 1.4 Turbo, A14NET or B14NET",
     "any_token": ["1.4t"], "makes": ["vauxhall", "opel"]},
    {"name": "Vauxhall and Opel 1.4 Turbo, A14NET or B14NET",
     "any_token": ["a14net", "b14net"]},

    {"name": "Fiat and Alfa 0.9 TwinAir",
     "all": ["0.9", "twinair"]},
    {"name": "Fiat and Alfa 0.9 TwinAir",
     "all": ["twinair"]},

    {"name": "Jaguar and Land Rover 2.0 Ingenium petrol",
     "all": ["2.0", "ingenium"]},

    {"name": "Audi and VW 2.0 TFSI, EA888",
     "all": ["2.0", "tfsi"]},

    {"name": "Audi and VW 1.8 TFSI, EA888",
     "all": ["1.8", "tfsi"]},

    {"name": "Fiat 1.4 MultiAir, 500 and Tipo",
     "all": ["1.4", "multiair"]},

    {"name": "Vauxhall and Opel 1.0 Turbo, B10XFL",
     "all": ["1.0", "turbo"], "makes": ["vauxhall", "opel"]},
    {"name": "Vauxhall and Opel 1.0 Turbo, B10XFL",
     "all": ["b10xfl"]},
]


# Detection of the banned engines by make, litres and fuel, for when the
# platform data does not spell out the family name (the Motorway CSV gives
# engine size and fuel, not "TSI" or "EcoBoost"). These all target petrol.
# This deliberately errs towards excluding petrol turbos, which is the intended
# behaviour. Diesels are unaffected. Year limits apply where the ban is year
# specific.
LITRE_BANS = [
    {"name": "1.2 PureTech", "makes": {"peugeot", "citroen", "ds", "vauxhall", "opel"}, "litres": "1.2"},
    {"name": "VW and Audi 1.5 TSI", "makes": {"volkswagen", "vw", "audi", "seat", "skoda", "cupra"}, "litres": "1.5"},
    {"name": "VW and Audi 2.0 TFSI", "makes": {"volkswagen", "vw", "audi", "seat", "skoda", "cupra"}, "litres": "2.0"},
    {"name": "VW and Audi 1.8 TFSI", "makes": {"volkswagen", "vw", "audi", "seat", "skoda", "cupra"}, "litres": "1.8"},
    {"name": "Ford 1.0 EcoBoost wet belt", "makes": {"ford"}, "litres": "1.0", "year_from": 2014, "year_to": 2019},
    {"name": "Ford 1.5 EcoBoost", "makes": {"ford"}, "litres": "1.5"},
    {"name": "Ford 2.0 EcoBoost, pre 2019", "makes": {"ford"}, "litres": "2.0", "year_to": 2018},
    {"name": "Hyundai or Kia 1.6 GDI", "makes": {"hyundai", "kia"}, "litres": "1.6"},
    {"name": "Nissan 1.2 DIG-T", "makes": {"nissan"}, "litres": "1.2"},
    {"name": "Renault, Nissan or Dacia 0.9 TCe", "makes": {"renault", "nissan", "dacia"}, "litres": "0.9"},
    {"name": "Renault 1.2 TCe", "makes": {"renault"}, "litres": "1.2"},
    {"name": "Vauxhall or Opel 1.0 Turbo", "makes": {"vauxhall", "opel"}, "litres": "1.0"},
    {"name": "Fiat, Alfa or Abarth 0.9 TwinAir", "makes": {"fiat", "alfa", "alfa romeo", "abarth"}, "litres": "0.9"},
    {"name": "Jaguar 2.0 Ingenium petrol", "makes": {"jaguar"}, "litres": "2.0"},
]


@dataclass
class Car:
    """One car as entered by hand for Phase 1. Reads from the platforms come
    later. Money fields are whole pounds. Missing optional fields are None."""
    reg: str = ""
    make: str = ""
    model: str = ""
    derivative: str = ""
    year: Optional[int] = None
    mileage: Optional[int] = None
    owners: Optional[int] = None
    grade: Optional[int] = None
    reserve: Optional[int] = None
    cap_clean: Optional[int] = None
    distance_miles: Optional[float] = None
    engine: str = ""
    transmission: str = ""             # Manual, Automatic, Semi-automatic, CVT
    fuel: str = ""                     # Petrol, Diesel, Hybrid, Electric
    body_type: str = ""                # Hatchback, SUV, Panel van, etc.
    equipment: str = ""                # equipment and spec text, used for alloys
    service_history: str = ""          # full, partial, none, or unknown
    vat_qualifying: bool = False
    glass_retail: Optional[int] = None
    cazana_retail: Optional[int] = None
    photo_url: str = ""
    listing_url: str = ""
    location: str = ""                 # text location, when exact miles are not given
    source: str = ""                   # Motorway or Carwow, free text for now
    actually_paid: Optional[int] = None  # what Steven really paid, for checking only


@dataclass
class Pricing:
    # A valuation that could not be read is None. The car is still priced on the
    # one that did read, so these two and their calc fields may be None.
    glass_retail: Optional[int]
    cazana_retail: Optional[int]
    calc_glass: Optional[int]
    calc_cazana: Optional[int]
    governing_value: int
    governing_source: str              # "Glass's" or "Cazana"
    max_bid: int


@dataclass
class Assessment:
    car: Car
    status: str                        # shortlist, held, or rejected
    reasons: list = field(default_factory=list)   # why rejected or held
    flags: list = field(default_factory=list)     # notes to show on the card
    pricing: Optional[Pricing] = None


def car_age(year: Optional[int], today: Optional[datetime.date] = None) -> Optional[int]:
    if year is None:
        return None
    today = today or datetime.date.today()
    return today.year - int(year)


def _grade_int(grade) -> Optional[int]:
    """Grade may arrive as 1, '1', or '1, clean'. Take the leading number."""
    if grade is None:
        return None
    m = re.search(r"\d+", str(grade))
    return int(m.group()) if m else None


def banned_make(car: Car) -> Optional[str]:
    make = _norm(car.make)
    combined = _norm(f"{car.make} {car.model}")
    if make in BANNED_MAKES:
        return car.make.strip().title()
    # Motorway's CSV sometimes splits "Land Rover" into make "Land" with the
    # rest in the model, and Range Rover is a Land Rover. No legitimate make is
    # just "Land", so catch all of these.
    if make.startswith("land") or "land rover" in combined or "range rover" in combined:
        return "Land Rover"
    return None


def banned_engine(car: Car) -> Optional[str]:
    """Return the name of the matching ban, or None. Year and make conditions
    are applied where a rule defines them."""
    engine = _norm(car.engine)
    make = _norm(car.make)
    for rule in BANNED_ENGINES:
        if "makes" in rule and make not in rule["makes"]:
            continue
        if "year_from" in rule and (car.year is None or int(car.year) < rule["year_from"]):
            continue
        if "year_to" in rule and (car.year is None or int(car.year) > rule["year_to"]):
            continue
        if "all" in rule and not all(tok in engine for tok in rule["all"]):
            continue
        if "any_token" in rule and not any(tok in engine for tok in rule["any_token"]):
            continue
        return rule["name"]

    # Detect by make, litres and fuel where the family name is not in the text.
    litres_m = re.search(r"\b(\d\.\d)\b", engine)
    litres = litres_m.group(1) if litres_m else None
    is_petrol = "petrol" in engine
    if litres and is_petrol:
        for rule in LITRE_BANS:
            if make not in rule["makes"] or litres != rule["litres"]:
                continue
            if "year_from" in rule and (car.year is None or int(car.year) < rule["year_from"]):
                continue
            if "year_to" in rule and (car.year is None or int(car.year) > rule["year_to"]):
                continue
            return rule["name"]
    return None


def class_exclusion(car: Car) -> Optional[str]:
    """Whole classes Steven does not buy, decided during the live review:
    CVT gearboxes, electric and hybrid, and vans and commercials. Returns the
    reason, or None. Normal automatics are allowed, only CVT is excluded."""
    trans = _norm(car.transmission)
    fuel = _norm(car.fuel)
    body = _norm(car.body_type)
    combined = _norm(f"{car.make} {car.model} {car.engine}")

    if trans == "cvt" or "cvt" in combined:
        return "CVT gearbox, not bought."

    electrified = ("mhev", "phev", "hybrid", "e-golf", "e-c4", "500e", " ev", "ev ",
                   "electric", "plug in", "plug-in", "range extender", " hev")
    if fuel in ("hybrid", "electric") or any(t in combined for t in electrified):
        return "Electric or hybrid, not bought."

    if body in ("panel van", "van") or any(v in combined for v in VAN_MODELS):
        return "Van or commercial, not bought."

    if body == "convertible":
        return "Convertible, not bought."

    make = _norm(car.make)
    model_toks = set(_norm(car.model).replace("+", " ").split())
    for mk, tok in BANNED_MODELS:
        if make == mk and tok in model_toks:
            return f"{car.make} {car.model}, not bought."

    if make == "ford" and trans in ("automatic", "semi-automatic", "semi automatic"):
        return "Automatic Ford, not bought."

    cap = car.cap_clean
    if cap is not None:
        for rule in VALUE_CAPS:
            if make != rule["make"]:
                continue
            if rule.get("model") and rule["model"] not in model_toks:
                continue
            if any(ex in _norm(car.model) for ex in rule.get("except", [])):
                continue
            if cap > rule["cap"]:
                label = (rule["model"] or car.make).title()
                return f"{label} with CAP clean over {pounds(rule['cap'])}, not bought."

    if car.cap_clean is not None and car.cap_clean > SMALL_CAR_CAP_LIMIT and is_small_city_car(car):
        return f"Small city car with CAP clean over {pounds(SMALL_CAR_CAP_LIMIT)}, not bought."

    # Note: the wheel trims versus alloys rule is not enforced here. The platform
    # data does not reliably say whether a car has alloys (the Motorway CSV
    # equipment field lists it for almost none), so filtering on it would wrongly
    # exclude most cars. Left for a visual check or a future detail page read.

    return None


def gate_failures(car: Car, today: Optional[datetime.date] = None,
                  assume_distance_ok: bool = False) -> list:
    """Every hard gate breach, in plain English with no hyphens or dashes.
    assume_distance_ok is set when the car came through a source that already
    enforces the 210 mile limit (the Motorway brief export gives a location, not
    exact miles), so the distance check is trusted to the filter."""
    fails = []

    if car.mileage is None:
        fails.append("Mileage is missing, so it cannot pass the gate.")
    elif car.mileage >= MAX_MILEAGE:
        fails.append(f"Mileage {car.mileage:,} is on or over the 90,000 limit.")

    if car.reserve is None:
        fails.append("Reserve price is missing, so it cannot pass the gate.")
    elif car.reserve >= MAX_RESERVE:
        fails.append(f"Reserve {pounds(car.reserve)} is on or over the {pounds(MAX_RESERVE)} limit.")

    age = car_age(car.year, today)
    if age is None:
        fails.append("Year is missing, so age cannot be checked.")
    elif age < MIN_AGE or age > MAX_AGE:
        fails.append(f"Age {age} years is outside the 2 to 10 years window.")

    if assume_distance_ok:
        pass  # distance enforced by the source filter
    elif car.distance_miles is None:
        fails.append("Distance from NE3 5HE is missing, so it cannot pass the gate.")
    elif car.distance_miles > MAX_DISTANCE_MILES:
        fails.append(f"Distance {car.distance_miles:g} miles is over the 210 mile limit.")

    grade = _grade_int(car.grade)
    if grade is None:
        fails.append("Condition grade is missing, so it cannot pass the gate.")
    elif grade not in ALLOWED_GRADES:
        fails.append(f"Condition grade {grade} is not 1, 2 or 3.")

    if car.owners is None:
        fails.append("Previous owners count is missing, so it cannot pass the gate.")
    elif car.owners > MAX_OWNERS:
        fails.append(f"{car.owners} previous owners is over the limit of 4.")

    return fails


def price_car(car: Car) -> Optional[Pricing]:
    """Apply section 3.1. Returns None only when BOTH valuations are missing, so
    the car cannot be priced at all and the caller holds it back. If exactly one
    valuation reads, the car is priced on that one (not a guess, a real read) and
    the caller flags that the other could not be read."""
    glass = int(car.glass_retail) if car.glass_retail is not None else None
    cazana = int(car.cazana_retail) if car.cazana_retail is not None else None

    if glass is None and cazana is None:
        return None

    calc_glass = round(glass * (1 + RETAIL_UPLIFT)) if glass is not None else None
    calc_cazana = round(cazana * (1 + RETAIL_UPLIFT)) if cazana is not None else None

    if calc_glass is not None and calc_cazana is not None:
        # Both read: the signed off rule. Over the threshold Glass's governs,
        # otherwise take the higher of the two calculated values.
        if calc_glass > GOVERNING_THRESHOLD:
            governing, governing_source = calc_glass, "Glass's"
        elif calc_cazana > calc_glass:
            governing, governing_source = calc_cazana, "Cazana"
        else:
            governing, governing_source = calc_glass, "Glass's"
    elif calc_glass is not None:
        # Only Glass's read. Price on it, the other is flagged by the caller.
        governing, governing_source = calc_glass, "Glass's"
    else:
        # Only Cazana read.
        governing, governing_source = calc_cazana, "Cazana"

    max_bid = governing - FLAT_SPREAD

    return Pricing(
        glass_retail=glass,
        cazana_retail=cazana,
        calc_glass=calc_glass,
        calc_cazana=calc_cazana,
        governing_value=governing,
        governing_source=governing_source,
        max_bid=max_bid,
    )


def _service_flag(car: Car) -> Optional[str]:
    sh = _norm(car.service_history)
    if sh in ("full", "fsh", "full service history"):
        return None  # preferred, nothing to flag
    if sh in ("", "unknown"):
        return "Service history not confirmed. Treat as a lower quality buy."
    if sh in ("none", "no", "no service history"):
        return "No service history. Lower quality buy."
    if sh in ("partial", "part"):
        return "Partial service history only. Lower quality buy."
    return f"Service history: {car.service_history}. Lower quality buy."


def assess(car: Car, today: Optional[datetime.date] = None,
           assume_distance_ok: bool = False) -> Assessment:
    """Run the whole brain on one car and return a full assessment."""
    reasons = []

    # 1. Banned make is an instant rejection.
    bm = banned_make(car)
    if bm:
        reasons.append(f"{bm} is a banned make.")
        return Assessment(car=car, status="rejected", reasons=reasons)

    # 2. Engine must be known. Uncertainty drops the car.
    if not _norm(car.engine):
        reasons.append("Engine could not be confirmed, so the car is excluded. Play safe.")
        return Assessment(car=car, status="rejected", reasons=reasons)

    # 3. Banned engine is an instant rejection.
    be = banned_engine(car)
    if be:
        reasons.append(f"Banned engine: {be}.")
        return Assessment(car=car, status="rejected", reasons=reasons)

    # 3b. Whole classes Steven does not buy: CVT, electric and hybrid, vans.
    ce = class_exclusion(car)
    if ce:
        reasons.append(ce)
        return Assessment(car=car, status="rejected", reasons=reasons)

    # 4. The hard gate. All must pass.
    fails = gate_failures(car, today, assume_distance_ok=assume_distance_ok)
    if fails:
        return Assessment(car=car, status="rejected", reasons=fails)

    # The car is allowed. Now build the flags shown on the card.
    flags = []
    sf = _service_flag(car)
    if sf:
        flags.append(sf)
    if car.vat_qualifying:
        flags.append("VAT qualifying. Priced by the normal rule, just flagged.")

    # 5. Price it. Only hold back when BOTH valuations are missing (cannot price
    # at all). If one reads, price on it and flag that the other could not be
    # read, so the car still shows rather than being held on a guess.
    pricing = price_car(car)
    if pricing is None:
        reasons.append(
            "Held back. Neither Glass's retail nor Cazana retail could be read "
            "with confidence. Not priced on a guess."
        )
        return Assessment(car=car, status="held", reasons=reasons, flags=flags)

    if car.glass_retail is None:
        flags.append("Glass's valuation could not be read. Priced on Cazana only.")
    elif car.cazana_retail is None:
        flags.append("Cazana valuation could not be read. Priced on Glass's only.")

    return Assessment(car=car, status="shortlist", flags=flags, pricing=pricing)


def pounds(value) -> str:
    """Format whole pounds as a clean string with no dashes."""
    if value is None:
        return "not read"
    return "£{:,}".format(int(round(value)))


def assess_all(cars, today: Optional[datetime.date] = None):
    """Assess a list of cars and return the shortlist sorted cheapest reserve
    first, plus the held and rejected lists."""
    assessments = [assess(c, today) for c in cars]

    shortlist = [a for a in assessments if a.status == "shortlist"]
    held = [a for a in assessments if a.status == "held"]
    rejected = [a for a in assessments if a.status == "rejected"]

    # Cheapest reserve first. A car on the shortlist always has a reserve,
    # because a missing reserve fails the gate.
    shortlist.sort(key=lambda a: a.car.reserve)

    return shortlist, held, rejected
