"""HTTP client for the ERP tank certificate data API."""
from __future__ import annotations

import base64
import urllib.parse
from calendar import monthrange
from datetime import date
from typing import Any, Optional

import httpx

from ..config import get_settings
from ..logger import get_logger
from ..schemas import TankAllocationCommand, TankAllocationRequestedEvent

log = get_logger(__name__)


def last_day_of_month(value: str | None) -> str | None:
    """BASF requires the LAST DAY of the inspection month, e.g. '2026-12' -> '20261231'.

    The upstream cert-extraction AI is inconsistent about the format it returns
    for these month values — some tanks come back as ``YYYY-MM`` (e.g.
    ``"2028-06"``), others as ``MM-YYYY`` (e.g. ``"06-2028"``), some as
    a full date like ``YYYY-MM-DD`` / ``DD-MM-YYYY``, and some use ``/``
    instead of ``-`` as the separator (e.g. ``"04/26"`` for MM/YY). Accept
    all common variants defensively so one bad shape doesn't crash the
    entire tank-data minion (which would leave the allocation stuck at
    REQUEST_ACCEPTED forever, because the Kafka message never gets acked).
    """
    if not value:
        return None
    normalized = str(value).replace("/", "-")
    try:
        parts = [int(x) for x in normalized.split("-") if x.strip()]
    except ValueError:
        log.warning("last_day_of_month: cannot parse %r", value)
        return None
    if len(parts) < 2:
        log.warning("last_day_of_month: unexpected shape %r", value)
        return None

    year: int | None = None
    month: int | None = None
    # Pick whichever token looks like a 4-digit year, and whichever looks
    # like a month (1..12). Falls back to first=year/second=month for
    # ambiguous 2-digit/2-digit input.
    for p in parts:
        if 1900 <= p <= 2100 and year is None:
            year = p
        elif 1 <= p <= 12 and month is None:
            month = p
    if year is None or month is None:
        # Last-resort ISO-order guess: YYYY-MM[-DD]
        if 1900 <= parts[0] <= 2100 and 1 <= parts[1] <= 12:
            year, month = parts[0], parts[1]
        # MM-YYYY[-...]
        elif len(parts) >= 2 and 1 <= parts[0] <= 12 and 1900 <= parts[1] <= 2100:
            month, year = parts[0], parts[1]
        # 2-digit year variants (MM/YY or YY/MM) — e.g. "04/26" meaning
        # April 2026, or "25/08/23" meaning day/month/2-digit-year. Assume
        # 2-digit years are 20xx (fine for tank inspection dates for the
        # foreseeable future).
        elif len(parts) == 2 and 1 <= parts[0] <= 12 and 0 <= parts[1] <= 99:
            month, year = parts[0], 2000 + parts[1]
        elif len(parts) == 2 and 0 <= parts[0] <= 99 and 1 <= parts[1] <= 12:
            year, month = 2000 + parts[0], parts[1]
        else:
            log.warning("last_day_of_month: cannot resolve year/month from %r", value)
            return None

    try:
        last_day = monthrange(year, month)[1]
        return date(year, month, last_day).strftime("%Y%m%d")
    except (ValueError, OverflowError) as exc:
        log.warning("last_day_of_month: %r → %s", value, exc)
        return None


class TankDataIncompleteError(Exception):
    """Raised when neither the AI cert-extraction nor the plain ERP tank
    record has enough usable data to build a BASF allocation payload — or
    the AI response is only partially populated (see
    ``extract_tank_fields`` for the exact rules). The caller (tank-data-
    minion) catches this and reports a FAILED status back to ERP instead of
    sending a guessed/incomplete payload to BASF.
    """

    def __init__(self, reason: str):
        self.reason = reason
        super().__init__(reason)


def extract_tank_fields(tank_data: dict[str, Any]) -> tuple[Optional[dict[str, Any]], Optional[str]]:
    """Resolve the fields BASF needs.

    Rule (simple, per-field): for each of the 5 target fields, prefer the
    AI cert-extraction value (``extras.ai_response``). If — and only if —
    that specific field is empty/null in the AI response, take that one
    field from the plain ERP tank record (``tank_data["erp"]``) instead.
    Each field maps 1:1 onto an ERP `tank` table column, so this is safe
    (not "guessing" or mixing unrelated data) and lets a cert scan that's
    illegible for one or two fields (T-code plate, baffle markings, etc.)
    still succeed using ERP's own master data for just those fields.

    Uses ONLY the plain ERP tank record (``tank_data["erp"]``) — the AI
    cert-extraction values (``extras.ai_response``) are no longer used as
    the primary source. Any field missing/null in ERP defaults to ``0``
    (except ``next_tank_inspection``, which has no sane numeric default —
    BASF requires an actual date, so a missing inspection date still fails
    with reason "incomplete_tank_data").

    Returns ``(fields, None)`` on success, or ``(None, reason)`` on failure.
    """
    erp = tank_data.get("erp") or {}
    if not erp:
        log.warning("extract_tank_fields: no usable data in erp")
        return None, "no_tank_data"

    erp_baffles = erp.get("tank_baffles")
    values = {
        "empty_weight": erp.get("tank_weight") if erp.get("tank_weight") is not None else 0,
        "volume": erp.get("tank_capacity") if erp.get("tank_capacity") is not None else 0,
        "next_tank_inspection": erp.get("tank_next_mot"),
        "ipt": erp.get("tank_t_no") if erp.get("tank_t_no") is not None else 0,
        "baffles_present": bool(erp_baffles) if erp_baffles is not None else False,
    }

    if values["next_tank_inspection"] is None:
        log.warning(
            "extract_tank_fields: missing next_tank_inspection in ERP data "
            "(erp.tank_next_mot is null/empty) — treating as incomplete",
        )
        return None, "incomplete_tank_data: next_tank_inspection (erp.tank_next_mot is null/empty)"

    log.info("extract_tank_fields: using source=erp")
    return {
        "empty_weight": values["empty_weight"],
        "volume": values["volume"],
        "next_tank_inspection": last_day_of_month(values["next_tank_inspection"]),
        "ipt": values["ipt"],
        "number_surge_plates": 3 if values["baffles_present"] else 0,
    }, None


def build_allocation_event(
    cmd: TankAllocationCommand, tank_data: dict[str, Any], doc: Any
) -> TankAllocationRequestedEvent:
    fields, reason = extract_tank_fields(tank_data)
    if fields is None:
        raise TankDataIncompleteError(reason or "no_tank_data")

    return TankAllocationRequestedEvent(
        job_id=cmd.job_id,
        tank_id=cmd.tank_id,
        licence_number=cmd.licence_number,
        internal_identifer=cmd.internal_identifer,
        vehicle_type=cmd.vehicle_type or get_settings().fastlane_default_vehicle_type,
        is_dangerous_goods=bool(cmd.is_dangerous_goods),
        empty_weight=fields["empty_weight"],
        volume=fields["volume"],
        next_tank_inspection=fields["next_tank_inspection"],
        ipt=fields["ipt"],
        number_surge_plates=fields["number_surge_plates"],
        documents=[doc],
        commit_ready=True,
        basf_ref=cmd.basf_ref,
        requested_by=cmd.requested_by,
        # CRITICAL: forward the Fastlane identifiers ERP already knows about.
        # Without these the downstream lookup-minion has no way of knowing
        # this tank is already registered on BASF, does a fresh
        # getPublishedVersion (which fails because BASF-side licence_number
        # is normalised — see fastlane_client._normalize_licence_for_basf),
        # returns MISS, and upsert-minion creates a DUPLICATE asset instead
        # of updating the existing one. This is the doc-upload path bug:
        # tank_core_controller.notifyFastlaneDocumentUpload() reads
        # fastlane_tank.fastlane_asset_id + incremental_version from the
        # ERP DB and sends them here specifically so we can drive an
        # updateAsset call against the existing asset.
        fastlane_asset_id=cmd.fastlane_asset_id,
        incremental_version=cmd.incremental_version,
        metadata=cmd.metadata,
    )


async def fetch_tank_data(licence_number: str) -> tuple[Optional[dict[str, Any]], dict[str, Any]]:
    """Fetch structured cert data for a tank by tank number.

    Returns (data, audit). data is None when the tank has no data.
    audit always contains the outbound request and inbound response so the
    caller can persist a full request/response record.
    """
    s = get_settings()
    encoded = urllib.parse.quote(licence_number, safe="")
    url = f"{s.tank_data_api_url}/{encoded}?doc_type=auto"
    headers = {"Accept": "*/*", "X-API-Key": s.tank_data_api_key}

    audit: dict[str, Any] = {"request": {"url": url, "method": "GET"}}
    try:
        # This endpoint runs OCR/AI extraction synchronously and can take
        # 30-50+ seconds, so it uses its own, longer timeout.
        async with httpx.AsyncClient(timeout=s.tank_data_timeout) as c:
            r = await c.get(url, headers=headers)
    except httpx.TimeoutException as exc:
        audit["response"] = {"error": str(exc), "timeout": True}
        log.warning("Tank data API call timed out for %s: %s", licence_number, exc)
        return None, audit
    except httpx.HTTPError as exc:
        audit["response"] = {"error": str(exc)}
        log.warning("Tank data API call failed for %s: %s", licence_number, exc)
        return None, audit

    try:
        body = r.json()
    except ValueError:
        body = r.text

    audit["response"] = {"status_code": r.status_code, "body": body}

    if r.status_code != 200 or not body:
        return None, audit
    return body, audit


async def fetch_tank_document(licence_number: str) -> tuple[Optional[dict[str, Any]], dict[str, Any]]:
    """Fetch the tank certificate document by tank number.

    The upstream endpoint (``latest-cert``) returns the raw PDF binary.
    BASF's ``uploadFile`` (and this project's ``file_upload_minion``) both
    expect a document dict shaped like::

        {"name": "xxx.pdf", "fields": ["next_tank_inspection", ...],
         "content_base64": "<base64 of the pdf bytes>"}

    So we read as bytes, base64-encode, and return the dict. Previously
    this returned the raw PDF as a *string*, which crashed the
    tank-data-minion at Pydantic validation (documents.0 must be a dict),
    leaving allocations stuck at REQUEST_ACCEPTED forever.

    Returns (doc_dict, audit). doc_dict is None when unavailable. The
    audit deliberately omits the PDF payload to keep audit logs small.
    """
    s = get_settings()
    encoded = urllib.parse.quote(licence_number, safe="")
    url = f"{s.tank_doc_api_url}/{encoded}?doc_type=auto"
    headers = {"Accept": "*/*", "X-API-Key": s.tank_data_api_key}

    audit: dict[str, Any] = {"request": {"url": url, "method": "GET"}}
    try:
        async with httpx.AsyncClient(timeout=s.tank_data_timeout) as c:
            r = await c.get(url, headers=headers)
    except httpx.TimeoutException as exc:
        audit["response"] = {"error": str(exc), "timeout": True}
        log.warning("Tank document API call timed out for %s: %s", licence_number, exc)
        return None, audit
    except httpx.HTTPError as exc:
        audit["response"] = {"error": str(exc)}
        log.warning("Tank document API call failed for %s: %s", licence_number, exc)
        return None, audit

    if r.status_code != 200 or not r.content:
        # Try to surface the JSON error body (e.g. {"detail": "..."}) but
        # never the raw PDF bytes.
        try:
            body_preview: Any = r.json()
        except ValueError:
            body_preview = r.text[:500] if r.text else None
        audit["response"] = {"status_code": r.status_code, "body": body_preview}
        return None, audit

    # Success: content is the raw PDF binary. Base64-encode it for the
    # downstream BASF uploadFile payload.
    content_bytes = r.content
    content_b64 = base64.b64encode(content_bytes).decode("ascii")

    # Try to derive a friendly filename from Content-Disposition; otherwise
    # fall back to <plate>.pdf.
    filename = f"{licence_number.replace('/', '_')}.pdf"
    disp = r.headers.get("content-disposition") or ""
    if "filename=" in disp:
        try:
            filename = disp.split("filename=", 1)[1].strip().strip('"; ')
        except Exception:  # noqa: BLE001
            pass

    doc: dict[str, Any] = {
        "name": filename,
        # Fields this cert validates — the file-upload-minion will merge
        # this with the full list of populated slugs on the asset (§2.4).
        # "next_tank_inspection" is the primary one because the periodic
        # test cert is what proves the next-inspection date.
        "fields": ["next_tank_inspection"],
        "content_base64": content_b64,
    }
    audit["response"] = {
        "status_code": r.status_code,
        "bytes": len(content_bytes),
        "filename": filename,
        "content_type": r.headers.get("content-type"),
    }
    return doc, audit
