from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import time
import webbrowser
from dataclasses import dataclass
from http.cookies import SimpleCookie
from typing import Any
from urllib.parse import urljoin

import requests


DEFAULT_BASE_URL = "https://start.aktivvo.padam.io"
DEFAULT_TIMEOUT = 20
PADAM_APP_NAME = "bookingzone"
BROWSER_CONFIGS = {
    "chrome": {
        "cookies": "~/Library/Application Support/Google/Chrome/{profile}/Cookies",
        "keychain": "Chrome Safe Storage",
        "app_name": "Google Chrome",
        "binary": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    },
    "chromium": {
        "cookies": "~/Library/Application Support/Chromium/{profile}/Cookies",
        "keychain": "Chromium Safe Storage",
        "app_name": "Chromium",
        "binary": "/Applications/Chromium.app/Contents/MacOS/Chromium",
    },
    "brave": {
        "cookies": "~/Library/Application Support/BraveSoftware/Brave-Browser/{profile}/Cookies",
        "keychain": "Brave Safe Storage",
        "app_name": "Brave Browser",
        "binary": "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
    },
    "edge": {
        "cookies": "~/Library/Application Support/Microsoft Edge/{profile}/Cookies",
        "keychain": "Microsoft Edge Safe Storage",
        "app_name": "Microsoft Edge",
        "binary": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
    },
    "arc": {
        "cookies": "~/Library/Application Support/Arc/{profile}/Cookies",
        "keychain": "Arc Safe Storage",
        "app_name": "Arc",
        "binary": "/Applications/Arc.app/Contents/MacOS/Arc",
    },
}


class AktivvoCliError(RuntimeError):
    pass


class CaptchaRequiredError(AktivvoCliError):
    pass


def _json_default(value: Any) -> Any:
    if isinstance(value, bytes):
        return value.hex()
    raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")


def _parse_json_argument(raw: str | None, *, default: Any) -> Any:
    if raw is None:
        return default
    return json.loads(raw)


def _dump(data: Any) -> None:
    json.dump(data, sys.stdout, ensure_ascii=False, indent=2, default=_json_default)
    sys.stdout.write("\n")


def _load_cookie_map(cookie_header: str | None) -> dict[str, str]:
    if not cookie_header:
        return {}
    cookie = SimpleCookie()
    cookie.load(cookie_header)
    return {key: morsel.value for key, morsel in cookie.items()}


def _extract_auth(cookie_header: str | None, explicit_token: str | None) -> tuple[str | None, str]:
    if explicit_token:
        return explicit_token, "Token"
    cookie_map = _load_cookie_map(cookie_header)
    token = cookie_map.get("authz_pdm")
    if not token:
        return None, "Token"
    scheme = "Bearer" if cookie_map.get("isLoggedWithOpenid") else "Token"
    return token, scheme


def _normalize_auth_token(raw_value: str | bytes | None) -> str | None:
    if raw_value is None:
        return None
    if isinstance(raw_value, bytes):
        text = raw_value.decode("latin1", errors="ignore")
    else:
        text = raw_value
    if text.isascii() and text.isprintable():
        return text
    matches = re.findall(r"[0-9a-f]{32,128}", text)
    if matches:
        return matches[-1]
    return text


def _shell_quote(value: str) -> str:
    return "'" + value.replace("'", "'\"'\"'") + "'"


def _read_json_file(path: str) -> Any:
    if path == "-":
        return json.load(sys.stdin)
    with open(path, "r", encoding="utf-8") as handle:
        return json.load(handle)


def _load_search_payload_from_file(path: str) -> Any:
    raw = _read_json_file(path)
    return raw.get("response", raw)


def _derive_booking_ids(
    data: Any,
    section_id: int | None = None,
    proposition_id: int | None = None,
    proposal_index: int = 0,
) -> dict[str, Any]:
    inspected = _inspect_search_response(data)
    reservation_history_id = inspected.get("reservation_history_id")
    drt_sections = [section for section in inspected.get("sections", []) if section.get("mode") == "padam_drt"]
    if section_id is not None:
        drt_sections = [section for section in drt_sections if section.get("id") == section_id]
    if drt_sections:
        section = drt_sections[0]
        customer_proposition_id = proposition_id or section.get("drt_proposition_id") or section.get("id")
        reservation_history_id = section.get("drt_search_id") or reservation_history_id
        source = {"type": "section", "value": section}
    else:
        proposed_datetimes = inspected.get("proposed_datetimes", [])
        if proposition_id is not None:
            matches = [item for item in proposed_datetimes if item.get("id") == proposition_id]
            if not matches:
                raise AktivvoCliError(f"Proposition {proposition_id} not found in proposed_datetimes.")
            selected = matches[0]
        else:
            if not proposed_datetimes:
                raise AktivvoCliError("No bookable proposition found in the search response.")
            if proposal_index < 0 or proposal_index >= len(proposed_datetimes):
                raise AktivvoCliError(
                    f"proposal-index {proposal_index} is out of range for {len(proposed_datetimes)} proposed_datetimes."
                )
            selected = proposed_datetimes[proposal_index]
        customer_proposition_id = selected.get("id")
        source = {"type": "proposed_datetime", "value": selected}
    if not reservation_history_id or not customer_proposition_id:
        raise AktivvoCliError(
            "Could not derive reservation_history_id or customer_proposition_id from the search response."
        )
    return {
        "reservation_history_id": reservation_history_id,
        "customer_proposition_id": customer_proposition_id,
        "source": source,
        "inspection": inspected,
    }


def _resolve_cookie_db_path(browser: str, profile: str) -> str:
    template = BROWSER_CONFIGS[browser]["cookies"]
    return os.path.expanduser(template.format(profile=profile))


def _get_keychain_password(service_name: str) -> str:
    command = ["security", "find-generic-password", "-w", "-s", service_name]
    try:
        result = subprocess.run(command, check=True, capture_output=True, text=True, timeout=10)
    except subprocess.TimeoutExpired as exc:
        raise AktivvoCliError(
            f"Timed out while waiting for macOS Keychain access to {service_name!r}. "
            "Unlock the prompt manually or pass --safe-storage-password."
        ) from exc
    except subprocess.CalledProcessError as exc:
        stderr = exc.stderr.strip()
        raise AktivvoCliError(f"Could not read macOS Keychain secret {service_name!r}: {stderr or exc}") from exc
    return result.stdout.strip()


def _decrypt_chromium_cookie_value(encrypted_value: bytes, safe_storage_password: str) -> str:
    if not encrypted_value:
        return ""
    if not encrypted_value.startswith((b"v10", b"v11")):
        return encrypted_value.decode("utf-8", errors="replace")

    try:
        from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
    except ImportError as exc:
        raise AktivvoCliError(
            "Cookie decryption needs the optional dependency 'cryptography'. "
            "Run `pip install -e .` first."
        ) from exc

    key = hashlib.pbkdf2_hmac("sha1", safe_storage_password.encode("utf-8"), b"saltysalt", 1003, dklen=16)
    iv = b" " * 16
    ciphertext = encrypted_value[3:]
    decryptor = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor()
    padded = decryptor.update(ciphertext) + decryptor.finalize()
    pad_length = padded[-1]
    if pad_length < 1 or pad_length > 16:
        raise AktivvoCliError("Unexpected cookie padding while decrypting Chromium cookies.")
    return padded[:-pad_length].decode("utf-8", errors="replace")


def _import_browser_cookies(
    browser: str,
    profile: str,
    host: str,
    include_subdomains: bool,
    safe_storage_password: str | None,
) -> dict[str, Any]:
    if sys.platform != "darwin":
        raise AktivvoCliError("Browser cookie import is currently implemented only for macOS.")
    cookie_db = _resolve_cookie_db_path(browser, profile)
    if not os.path.exists(cookie_db):
        raise AktivvoCliError(f"Cookie database not found: {cookie_db}")

    keychain_service = BROWSER_CONFIGS[browser]["keychain"]
    safe_storage_password = safe_storage_password or os.getenv("AKTIVVO_SAFE_STORAGE_PASSWORD") or _get_keychain_password(
        keychain_service
    )

    with tempfile.TemporaryDirectory(prefix="aktivvo-cookies-") as temp_dir:
        temp_db = os.path.join(temp_dir, "Cookies")
        shutil.copy2(cookie_db, temp_db)
        connection = sqlite3.connect(temp_db)
        try:
            cursor = connection.cursor()
            if include_subdomains:
                pattern = f"%{host}"
                cursor.execute(
                    """
                    SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly
                    FROM cookies
                    WHERE host_key = ? OR host_key = ? OR host_key LIKE ?
                    ORDER BY LENGTH(host_key) DESC, name ASC
                    """,
                    (host, f".{host}", pattern),
                )
            else:
                cursor.execute(
                    """
                    SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly
                    FROM cookies
                    WHERE host_key = ? OR host_key = ?
                    ORDER BY LENGTH(host_key) DESC, name ASC
                    """,
                    (host, f".{host}"),
                )
            rows = cursor.fetchall()
        finally:
            connection.close()

    cookies: list[dict[str, Any]] = []
    for host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly in rows:
        decrypted_value = value or _decrypt_chromium_cookie_value(encrypted_value, safe_storage_password)
        if not decrypted_value:
            continue
        cookies.append(
            {
                "host": host_key,
                "name": name,
                "value": decrypted_value,
                "path": path,
                "expires_utc": expires_utc,
                "secure": bool(is_secure),
                "http_only": bool(is_httponly),
            }
        )

    if not cookies:
        raise AktivvoCliError(f"No cookies found for host {host!r} in browser {browser!r} profile {profile!r}.")

    auth_cookie = next((cookie for cookie in cookies if cookie["name"] == "authz_pdm"), None)
    auth_token = _normalize_auth_token(auth_cookie["value"]) if auth_cookie else None
    auth_scheme = "Bearer" if any(cookie["name"] == "isLoggedWithOpenid" for cookie in cookies) else "Token"
    header_parts: list[str] = []
    for cookie in cookies:
        value = cookie["value"]
        if cookie["name"] == "authz_pdm":
            value = auth_token or value
        if not isinstance(value, str):
            continue
        if not value.isascii() or not value.isprintable():
            continue
        header_parts.append(f"{cookie['name']}={value}")
    header = "; ".join(header_parts)
    return {
        "browser": browser,
        "profile": profile,
        "host": host,
        "cookie_header": header,
        "cookie_names": [cookie["name"] for cookie in cookies],
        "auth_token_present": bool(auth_token),
        "auth_scheme": auth_scheme if auth_token else None,
        "auth_token": auth_token,
    }


def _emit_imported_session(imported: dict[str, Any], output_format: str) -> None:
    if output_format == "json":
        _dump(imported)
    elif output_format == "header":
        print(imported["cookie_header"])
    else:
        print(f"export AKTIVVO_COOKIE_HEADER={_shell_quote(imported['cookie_header'])}")
        if imported.get("auth_token"):
            print(f"export AKTIVVO_AUTH_TOKEN={_shell_quote(imported['auth_token'])}")


def _launch_browser_for_login(browser: str, profile: str, url: str) -> None:
    config = BROWSER_CONFIGS.get(browser, {})
    binary = config.get("binary")
    if sys.platform == "darwin" and binary and os.path.exists(binary):
        subprocess.Popen(
            [binary, f"--profile-directory={profile}", url],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        return

    app_name = config.get("app_name")
    if sys.platform == "darwin" and app_name:
        subprocess.Popen(
            ["open", "-a", app_name, url],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
        return

    if not webbrowser.open(url):
        raise AktivvoCliError(f"Could not open the login page automatically: {url}")


def _login_via_browser(
    *,
    browser: str,
    profile: str,
    host: str,
    include_subdomains: bool,
    safe_storage_password: str | None,
    login_url: str,
    timeout: int,
    poll_interval: float,
    launch_browser: bool,
    wait_for_new_token: bool,
) -> dict[str, Any]:
    initial: dict[str, Any] | None = None
    initial_error: AktivvoCliError | None = None
    try:
        initial = _import_browser_cookies(browser, profile, host, include_subdomains, safe_storage_password)
    except AktivvoCliError as exc:
        initial_error = exc

    initial_token = initial.get("auth_token") if initial else None
    if initial_token and not wait_for_new_token:
        return initial

    if launch_browser:
        _launch_browser_for_login(browser, profile, login_url)

    deadline = time.monotonic() + timeout
    last_error = initial_error
    while time.monotonic() < deadline:
        try:
            imported = _import_browser_cookies(browser, profile, host, include_subdomains, safe_storage_password)
        except AktivvoCliError as exc:
            last_error = exc
            time.sleep(poll_interval)
            continue

        auth_token = imported.get("auth_token")
        if auth_token and (not initial_token or auth_token != initial_token or not wait_for_new_token):
            return imported
        last_error = AktivvoCliError("No authenticated browser session detected yet.")
        time.sleep(poll_interval)

    if initial_token:
        raise AktivvoCliError(
            "Timed out waiting for a new auth token. "
            "An existing session is already present; rerun without --wait-for-new-token to reuse it."
        )
    if last_error is not None:
        raise AktivvoCliError(
            f"Timed out waiting for browser login on {host!r}. Last check: {last_error}"
        ) from last_error
    raise AktivvoCliError(f"Timed out waiting for browser login on {host!r}.")


@dataclass
class ClientConfig:
    base_url: str
    cookie_header: str | None
    auth_token: str | None
    auth_scheme: str
    timeout: int


class AktivvoClient:
    def __init__(self, config: ClientConfig) -> None:
        self.config = config
        self.session = requests.Session()
        self.session.headers.update(
            {
                "Padam-App-Name": PADAM_APP_NAME,
                "User-Agent": "aktivvo-cli/0.1.0",
            }
        )
        if config.cookie_header:
            self.session.headers["Cookie"] = config.cookie_header

    def _request(
        self,
        *,
        method: str,
        path: str,
        api: str,
        api_version: str | None = None,
        authenticated: bool = False,
        params: dict[str, Any] | None = None,
        payload: Any = None,
    ) -> Any:
        base_path = "/api/v1.7/" if api == "v1" else "/api/"
        url = urljoin(self.config.base_url.rstrip("/") + "/", base_path.lstrip("/"))
        url = urljoin(url, path.lstrip("/"))

        headers: dict[str, str] = {}
        if api_version:
            headers["Accept"] = f"application/json; version={api_version}"
        if payload is not None:
            headers["Content-Type"] = "application/json"
        if authenticated:
            if not self.config.auth_token:
                raise AktivvoCliError(
                    "This command needs authentication. Pass --auth-token or a Cookie header containing authz_pdm."
                )
            headers["Authorization"] = f"{self.config.auth_scheme} {self.config.auth_token}"

        response = self.session.request(
            method=method,
            url=url,
            headers=headers,
            params=params,
            json=payload,
            timeout=self.config.timeout,
        )

        content_type = response.headers.get("content-type", "")
        body = response.text
        if "text/html" in content_type and "Padam Captcha" in body:
            raise CaptchaRequiredError(
                "The API returned the CrowdSec / reCAPTCHA interstitial. "
                "Use a browser-approved session and pass its Cookie header with --cookie-header or AKTIVVO_COOKIE_HEADER."
            )
        if response.status_code >= 400:
            try:
                detail = response.json()
            except ValueError:
                detail = {"status": response.status_code, "body": body}
            raise AktivvoCliError(json.dumps(detail, ensure_ascii=False))
        if "application/json" in content_type:
            return response.json()
        try:
            return response.json()
        except ValueError:
            return {"status": response.status_code, "body": body}

    def search_nodes(self, territory: str, query: str | None, limit: int) -> Any:
        params: dict[str, Any] = {"territory": territory, "limit": limit}
        if query:
            params["search"] = query
        return self._request(method="GET", path="search-nodes", api="v1", params=params)

    def get_node_by_id(self, territory: str, node_id: int) -> dict[str, Any]:
        offset = 0
        while True:
            page = self._request(
                method="GET",
                path="search-nodes",
                api="v1",
                params={"territory": territory, "limit": 100, "offset": offset},
            )
            results = page.get("results", [])
            for node in results:
                if int(node["id"]) == node_id:
                    return node
            if not page.get("next"):
                break
            offset += len(results)
        raise AktivvoCliError(f"Node {node_id} not found in territory {territory!r}.")

    def current_customer(self) -> Any:
        return self._request(
            method="GET",
            path="customers/current/",
            api="v2",
            api_version="2.0",
            authenticated=True,
        )

    def current_product(self) -> Any:
        return self._request(
            method="GET",
            path="products/current/",
            api="v2",
            api_version="2.0",
            authenticated=False,
        )

    def search(self, territory: str, payload: dict[str, Any]) -> Any:
        return self._request(
            method="POST",
            path="get-rides-multinode",
            api="v1",
            payload=payload,
            params={"territory": territory},
        )

    def validate_search_proposition(
        self,
        *,
        search_request_id: int,
        proposition_id: int,
        customer_id: int,
        indications_to_driver: str,
    ) -> Any:
        path = f"searchrequests/{search_request_id}/propositions/{proposition_id}/validate/"
        return self._request(
            method="POST",
            path=path,
            api="v2",
            api_version="2.0",
            authenticated=True,
            payload={
                "customer_id": customer_id,
                "indications_to_driver": indications_to_driver,
            },
        )

    def validate_booking(self, territory: str, payload: dict[str, Any]) -> Any:
        response = self._request(
            method="POST",
            path="validate-booking",
            api="v1",
            authenticated=True,
            payload=payload,
            params={"territory": territory},
        )
        return {"territory": territory, "response": response}

    def patch_indications_to_driver(self, customer_id: int, booking_id: int, comment: str) -> Any:
        path = f"customers/{customer_id}/bookings/{booking_id}/"
        return self._request(
            method="PATCH",
            path=path,
            api="v2",
            api_version="2.0",
            authenticated=True,
            payload={"comment": comment},
        )


def _build_client(args: argparse.Namespace) -> AktivvoClient:
    cookie_header = args.cookie_header or os.getenv("AKTIVVO_COOKIE_HEADER")
    auth_token, auth_scheme = _extract_auth(cookie_header, args.auth_token or os.getenv("AKTIVVO_AUTH_TOKEN"))
    config = ClientConfig(
        base_url=args.base_url,
        cookie_header=cookie_header,
        auth_token=auth_token,
        auth_scheme=auth_scheme,
        timeout=args.timeout,
    )
    return AktivvoClient(config)


def _build_search_payload(args: argparse.Namespace, client: AktivvoClient) -> dict[str, Any]:
    from_node = client.get_node_by_id(args.territory, args.from_id)
    to_node = client.get_node_by_id(args.territory, args.to_id)
    payload = {
        "departure_position": {
            "address": from_node["name"],
            "latitude": from_node["position"]["latitude"],
            "longitude": from_node["position"]["longitude"],
        },
        "destination_position": {
            "address": to_node["name"],
            "latitude": to_node["position"]["latitude"],
            "longitude": to_node["position"]["longitude"],
        },
        "time_restriction_type": "DESTINATION" if args.arrival else "DEPARTURE",
        "passengers": _parse_json_argument(args.passengers_json, default=[]),
        "custom_fields": _parse_json_argument(args.custom_fields_json, default={}),
        "requirements": _parse_json_argument(args.requirements_json, default=[]),
        "came_from_maas_redirect": False,
        "datetime": args.datetime,
    }
    return payload


def _inspect_search_response(data: Any) -> dict[str, Any]:
    reservation_info = data.get("reservation_info", {}) if isinstance(data, dict) else {}
    sections = data.get("sections", []) if isinstance(data, dict) else []
    proposed_datetimes = reservation_info.get("proposed_datetimes", [])

    return {
        "reservation_history_id": reservation_info.get("id"),
        "proposed_datetimes": [
            {
                "id": item.get("id"),
                "requested_datetime": item.get("requested_datetime"),
                "proposed_datetime": item.get("proposed_datetime"),
                "discount_info": item.get("discount_info"),
            }
            for item in proposed_datetimes
        ],
        "sections": [
            {
                "id": section.get("id"),
                "mode": section.get("mode"),
                "drt_proposition_id": section.get("drt_proposition_id"),
                "drt_search_id": section.get("drt_search_id"),
                "price": section.get("final_price") or section.get("price"),
                "display": {
                    "departure_time": section.get("departure_time"),
                    "arrival_time": section.get("arrival_time"),
                    "from": section.get("from"),
                    "to": section.get("to"),
                },
            }
            for section in sections
        ],
        "raw_keys": sorted(list(data.keys())) if isinstance(data, dict) else [],
    }


def cmd_search_nodes(args: argparse.Namespace) -> int:
    client = _build_client(args)
    _dump(client.search_nodes(args.territory, args.query, args.limit))
    return 0


def cmd_current_customer(args: argparse.Namespace) -> int:
    client = _build_client(args)
    _dump(client.current_customer())
    return 0


def cmd_current_product(args: argparse.Namespace) -> int:
    client = _build_client(args)
    _dump(client.current_product())
    return 0


def cmd_search(args: argparse.Namespace) -> int:
    client = _build_client(args)
    payload = _build_search_payload(args, client)
    result = client.search(args.territory, payload)
    _dump({"request_payload": payload, "response": result})
    return 0


def cmd_inspect_search(args: argparse.Namespace) -> int:
    raw = _read_json_file(args.file)
    data = raw.get("response", raw)
    _dump(_inspect_search_response(data))
    return 0


def cmd_import_cookies(args: argparse.Namespace) -> int:
    imported = _import_browser_cookies(
        args.browser,
        args.profile,
        args.host,
        args.include_subdomains,
        args.safe_storage_password,
    )
    _emit_imported_session(imported, args.format)
    return 0


def cmd_login_browser(args: argparse.Namespace) -> int:
    login_url = args.login_url or urljoin(args.base_url.rstrip("/") + "/", "login")
    imported = _login_via_browser(
        browser=args.browser,
        profile=args.profile,
        host=args.host,
        include_subdomains=args.include_subdomains,
        safe_storage_password=args.safe_storage_password,
        login_url=login_url,
        timeout=args.wait_timeout,
        poll_interval=args.poll_interval,
        launch_browser=not args.no_open,
        wait_for_new_token=args.wait_for_new_token,
    )
    _emit_imported_session(imported, args.format)
    return 0


def cmd_validate_search_proposition(args: argparse.Namespace) -> int:
    client = _build_client(args)
    _dump(
        client.validate_search_proposition(
            search_request_id=args.search_request_id,
            proposition_id=args.proposition_id,
            customer_id=args.customer_id,
            indications_to_driver=args.indications,
        )
    )
    return 0


def cmd_book(args: argparse.Namespace) -> int:
    client = _build_client(args)
    payload = {
        "reservation_history_id": args.reservation_history_id,
        "customer_proposition_id": args.customer_proposition_id,
        "booked_from": args.booked_from,
        "payment": {
            "method": args.payment_method,
            "data": _parse_json_argument(args.payment_data_json, default={}),
        },
        "discount_info": _parse_json_argument(args.discount_info_json, default=[]),
    }
    if args.promo_code:
        payload["promo_code"] = args.promo_code

    response = client.validate_booking(args.territory, payload)
    if args.indications and args.customer_id:
        booking = response.get("response", {}).get("reservation", {})
        booking_id = booking.get("id")
        if booking_id:
            response["indications_patch"] = client.patch_indications_to_driver(
                customer_id=args.customer_id,
                booking_id=booking_id,
                comment=args.indications,
            )
    _dump({"request_payload": payload, "result": response})
    return 0


def cmd_book_from_search_response(args: argparse.Namespace) -> int:
    raw = _load_search_payload_from_file(args.file)
    derived = _derive_booking_ids(
        raw,
        section_id=args.section_id,
        proposition_id=args.proposition_id,
        proposal_index=args.proposal_index,
    )
    booking_args = argparse.Namespace(
        base_url=args.base_url,
        cookie_header=args.cookie_header,
        auth_token=args.auth_token,
        timeout=args.timeout,
        territory=args.territory,
        reservation_history_id=derived["reservation_history_id"],
        customer_proposition_id=derived["customer_proposition_id"],
        payment_method=args.payment_method,
        payment_data_json=args.payment_data_json,
        discount_info_json=args.discount_info_json,
        promo_code=args.promo_code,
        customer_id=args.customer_id,
        indications=args.indications,
        booked_from=args.booked_from,
    )
    client = _build_client(booking_args)
    payload = {
        "reservation_history_id": booking_args.reservation_history_id,
        "customer_proposition_id": booking_args.customer_proposition_id,
        "booked_from": booking_args.booked_from,
        "payment": {
            "method": booking_args.payment_method,
            "data": _parse_json_argument(booking_args.payment_data_json, default={}),
        },
        "discount_info": _parse_json_argument(booking_args.discount_info_json, default=[]),
    }
    if booking_args.promo_code:
        payload["promo_code"] = booking_args.promo_code

    response = client.validate_booking(booking_args.territory, payload)
    if booking_args.indications and booking_args.customer_id:
        booking = response.get("response", {}).get("reservation", {})
        booking_id = booking.get("id")
        if booking_id:
            response["indications_patch"] = client.patch_indications_to_driver(
                customer_id=booking_args.customer_id,
                booking_id=booking_id,
                comment=booking_args.indications,
            )

    _dump(
        {
            "derived_booking_ids": derived,
            "request_payload": payload,
            "result": response,
        }
    )
    return 0


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="aktivvo",
        description="CLI client for the Aktivvo / Padam booking API.",
    )
    parser.set_defaults(func=None)
    parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="Booking frontend base URL.")
    parser.add_argument(
        "--cookie-header",
        help="Exact Cookie header from a browser session that already solved the CrowdSec / reCAPTCHA gate.",
    )
    parser.add_argument(
        "--auth-token",
        help="Explicit auth token. If omitted, the CLI tries to extract authz_pdm from --cookie-header.",
    )
    parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="HTTP timeout in seconds.")

    subparsers = parser.add_subparsers(dest="command")

    def add_connection_args(command_parser: argparse.ArgumentParser) -> None:
        command_parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=argparse.SUPPRESS)
        command_parser.add_argument("--cookie-header", help=argparse.SUPPRESS)
        command_parser.add_argument("--auth-token", help=argparse.SUPPRESS)
        command_parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help=argparse.SUPPRESS)

    search_nodes = subparsers.add_parser("search-nodes", help="List search nodes for a territory.")
    add_connection_args(search_nodes)
    search_nodes.add_argument("--territory", required=True)
    search_nodes.add_argument("--query")
    search_nodes.add_argument("--limit", type=int, default=15)
    search_nodes.set_defaults(func=cmd_search_nodes)

    import_cookies = subparsers.add_parser(
        "import-cookies",
        help="Read Aktivvo cookies from a local Chromium-based browser profile on macOS.",
    )
    import_cookies.add_argument(
        "--browser",
        choices=sorted(BROWSER_CONFIGS.keys()),
        default="chrome",
    )
    import_cookies.add_argument("--profile", default="Default")
    import_cookies.add_argument("--host", default="start.aktivvo.padam.io")
    import_cookies.add_argument("--include-subdomains", action="store_true")
    import_cookies.add_argument(
        "--safe-storage-password",
        help="Override the macOS Safe Storage password instead of reading it from Keychain.",
    )
    import_cookies.add_argument("--format", choices=["json", "header", "env"], default="json")
    import_cookies.set_defaults(func=cmd_import_cookies)

    login_browser = subparsers.add_parser(
        "login-browser",
        help="Open the Aktivvo login page in a local browser and wait until authz_pdm appears in its cookie jar.",
    )
    add_connection_args(login_browser)
    login_browser.add_argument(
        "--browser",
        choices=sorted(BROWSER_CONFIGS.keys()),
        default="chrome",
    )
    login_browser.add_argument("--profile", default="Default")
    login_browser.add_argument("--host", default="start.aktivvo.padam.io")
    login_browser.add_argument("--include-subdomains", action="store_true")
    login_browser.add_argument(
        "--safe-storage-password",
        help="Override the macOS Safe Storage password instead of reading it from Keychain.",
    )
    login_browser.add_argument("--format", choices=["json", "header", "env"], default="env")
    login_browser.add_argument(
        "--login-url",
        help="Explicit login URL to open. Defaults to <base-url>/login.",
    )
    login_browser.add_argument(
        "--poll-interval",
        type=float,
        default=2.0,
        help="Seconds between cookie checks while waiting for the browser login to complete.",
    )
    login_browser.add_argument(
        "--wait-timeout",
        type=int,
        default=300,
        help="Maximum number of seconds to wait for authz_pdm to appear.",
    )
    login_browser.add_argument(
        "--no-open",
        action="store_true",
        help="Do not open a browser window. Only poll the selected browser profile for a new auth cookie.",
    )
    login_browser.add_argument(
        "--wait-for-new-token",
        action="store_true",
        help="Wait for authz_pdm to change instead of reusing an already logged-in session.",
    )
    login_browser.set_defaults(func=cmd_login_browser)

    current_product = subparsers.add_parser("current-product", help="Fetch the current product definition.")
    add_connection_args(current_product)
    current_product.set_defaults(func=cmd_current_product)

    current_customer = subparsers.add_parser("current-customer", help="Fetch the current authenticated customer.")
    add_connection_args(current_customer)
    current_customer.set_defaults(func=cmd_current_customer)

    search = subparsers.add_parser("search", help="Search rides using the same payload shape as the frontend.")
    add_connection_args(search)
    search.add_argument("--territory", required=True)
    search.add_argument("--from-id", type=int, required=True)
    search.add_argument("--to-id", type=int, required=True)
    search.add_argument("--datetime", required=True, help="ISO datetime, for example 2026-03-17T10:00:00+01:00")
    search.add_argument("--arrival", action="store_true", help="Use DESTINATION instead of DEPARTURE time restriction.")
    search.add_argument("--passengers-json", help="Override passengers payload as JSON.")
    search.add_argument("--custom-fields-json", help="Override custom_fields payload as JSON.")
    search.add_argument("--requirements-json", help="Override requirements payload as JSON.")
    search.set_defaults(func=cmd_search)

    inspect = subparsers.add_parser("inspect-search", help="Extract booking-relevant IDs from a search response JSON file.")
    inspect.add_argument("file", help="Path to a saved search response JSON file, or - for stdin.")
    inspect.set_defaults(func=cmd_inspect_search)

    validate_prop = subparsers.add_parser(
        "validate-search-proposition",
        help="Call the v2 search proposition validation endpoint.",
    )
    add_connection_args(validate_prop)
    validate_prop.add_argument("--search-request-id", type=int, required=True)
    validate_prop.add_argument("--proposition-id", type=int, required=True)
    validate_prop.add_argument("--customer-id", type=int, required=True)
    validate_prop.add_argument("--indications", default="")
    validate_prop.set_defaults(func=cmd_validate_search_proposition)

    book = subparsers.add_parser("book", help="Finalize a booking using validate-booking.")
    add_connection_args(book)
    book.add_argument("--territory", required=True)
    book.add_argument("--reservation-history-id", type=int, required=True)
    book.add_argument("--customer-proposition-id", type=int, required=True)
    book.add_argument("--payment-method", default="onboard")
    book.add_argument("--payment-data-json", help="Raw JSON for payment.data.")
    book.add_argument("--discount-info-json", help="Raw JSON array for discount_info.")
    book.add_argument("--promo-code")
    book.add_argument("--customer-id", type=int, help="Needed only if you also want to patch driver indications.")
    book.add_argument("--indications", default="")
    book.add_argument("--booked-from", default="BOOKED_FROM_WEBSITE")
    book.set_defaults(func=cmd_book)

    book_from_search = subparsers.add_parser(
        "book-from-search-response",
        help="Derive booking IDs from a saved search response and call validate-booking.",
    )
    add_connection_args(book_from_search)
    book_from_search.add_argument("file", help="Path to a saved search response JSON file, or - for stdin.")
    book_from_search.add_argument("--territory", required=True)
    book_from_search.add_argument("--section-id", type=int)
    book_from_search.add_argument("--proposition-id", type=int)
    book_from_search.add_argument("--proposal-index", type=int, default=0)
    book_from_search.add_argument("--payment-method", default="onboard")
    book_from_search.add_argument("--payment-data-json", help="Raw JSON for payment.data.")
    book_from_search.add_argument("--discount-info-json", help="Raw JSON array for discount_info.")
    book_from_search.add_argument("--promo-code")
    book_from_search.add_argument("--customer-id", type=int)
    book_from_search.add_argument("--indications", default="")
    book_from_search.add_argument("--booked-from", default="BOOKED_FROM_WEBSITE")
    book_from_search.set_defaults(func=cmd_book_from_search_response)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    if args.func is None:
        parser.print_help(sys.stderr)
        return 2

    try:
        return args.func(args)
    except CaptchaRequiredError as exc:
        print(f"captcha required: {exc}", file=sys.stderr)
        return 3
    except FileNotFoundError as exc:
        print(f"error: file not found: {exc.filename}", file=sys.stderr)
        return 1
    except json.JSONDecodeError as exc:
        print(f"error: invalid JSON input: {exc}", file=sys.stderr)
        return 1
    except AktivvoCliError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
