"""
Troi API Connector
==================
Supports both API flavors:
  - REST API:   /api/v2/rest/...  (OpenAPI-documented, preferred)
  - Legacy API: /api/v2/...       (calculationObjects-style)
  - Component API: /components/... (internal web-app endpoints, used as
    fallback when REST POST/DELETE return 500)

Auth: HTTP Basic (username + API key).
"""

from __future__ import annotations

import logging
import os
import re
import time
from typing import Any

import requests

from troi_mcp import __version__

logger = logging.getLogger("troi-mcp")


def _normalize_date(d: str | None) -> str | None:
    """Convert YYYY-MM-DD to YYYYMMDD (Troi's required format). Pass through if already YYYYMMDD."""
    if not d:
        return None
    return re.sub(r"(\d{4})-(\d{2})-(\d{2})", r"\1\2\3", d)


# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------

class TroiError(Exception):
    """Raised when the Troi API returns a non-success response."""

    def __init__(self, status_code: int, message: str, url: str = "", body: str = ""):
        super().__init__(f"[{status_code}] {message}")
        self.status_code = status_code
        self.url = url
        self.body = body


# ---------------------------------------------------------------------------
# Client
# ---------------------------------------------------------------------------

class TroiClient:
    """
    Thin HTTP wrapper for the Troi REST API.

    Usage:
        client = TroiClient(
            url="https://acme.troi.software",
            user="j.doe",          # username WITHOUT @domain
            api_key="abc123",
            client_id="1",
        )
        projects = client.list_projects()
    """

    # Rate limiting / retry configuration
    MAX_RETRIES = 5
    BASE_DELAY = 1.0        # seconds – exponential backoff base
    THROTTLE_DELAY = 0.15   # 150 ms pause between sequential requests (pagination)

    def __init__(
        self,
        url: str | None = None,
        user: str | None = None,
        api_key: str | None = None,
        client_id: str | None = None,
    ):
        self.base_url = (url or os.environ.get("TROI_URL", "")).rstrip("/")
        self.user = user or os.environ.get("TROI_USER", "")
        self.api_key = api_key or os.environ.get("TROI_API_KEY", "")
        self.client_id = client_id or os.environ.get("TROI_CLIENT_ID", "")

        if not self.base_url:
            raise ValueError("TROI_URL is required (e.g. https://acme.troi.software)")
        if not self.user or not self.api_key:
            raise ValueError("TROI_USER and TROI_API_KEY are required")
        if not self.client_id:
            raise ValueError("TROI_CLIENT_ID is required")

        self.session = requests.Session()
        self.session.auth = (self.user, self.api_key)
        self.session.headers.update({
            "Accept": "application/json",
            "Content-Type": "application/json",
            "User-Agent": "TroiMCP/1.0",
            "Accept-Language": "de-DE,de;q=0.9,en;q=0.5",
        })

        # Detect which API flavor works
        self._api_base: str | None = None
        self._session_ready = False  # True after a GET warms up the PHP session

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _ensure_session(self) -> None:
        """Warm up the PHP session with a trivial GET.

        The Troi v2 REST API's POST/DELETE endpoints for projects,
        subprojects, positions, and customers return a generic 500 error.
        However, Troi's *internal* component endpoints work when a valid
        PHP session cookie is present.  A single authenticated GET is
        enough to create the session.
        """
        if self._session_ready:
            return
        try:
            self.session.get(
                f"{self.base_url}/api/v2/rest/employees",
                params={"clientId": self.client_id},
                timeout=10,
            )
        except requests.RequestException:
            pass
        self._session_ready = True

    def _component_post(self, path: str, data: dict) -> Any:
        """POST to an internal /components/… endpoint."""
        self._ensure_session()
        url = f"{self.base_url}{path}"
        r = self.session.post(url, json=data, timeout=15)
        return self._handle(r, url)

    def _component_delete(self, path: str) -> Any:
        """DELETE on an internal /components/… endpoint."""
        self._ensure_session()
        url = f"{self.base_url}{path}"
        r = self.session.delete(url, timeout=15)
        return self._handle(r, url)

    def _form_post(self, path: str, data: dict | list) -> requests.Response:
        """POST form-encoded data to /site/index.php (legacy web UI endpoints).

        Returns the raw response (may be HTML) — caller decides how to handle it.
        """
        self._ensure_session()
        url = f"{self.base_url}{path}"
        r = self.session.post(
            url,
            data=data,
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "text/html,application/xhtml+xml,*/*",
            },
            timeout=15,
            allow_redirects=True,
        )
        if r.status_code >= 400:
            raise TroiError(r.status_code, f"Form POST failed: HTTP {r.status_code}", url, r.text[:500])
        return r

    def _detect_api(self) -> str:
        """Try REST API first, then legacy. Cache the result."""
        if self._api_base:
            return self._api_base

        # Try REST API: /api/v2/rest/clients
        for base in [f"{self.base_url}/api/v2/rest", f"{self.base_url}/api/v2"]:
            try:
                r = self.session.get(f"{base}/clients", timeout=10)
                if r.status_code in (200, 401, 403):
                    # 200 = works, 401/403 = endpoint exists but auth/perms issue
                    self._api_base = base
                    return base
            except requests.RequestException:
                continue

        # Default to REST API
        self._api_base = f"{self.base_url}/api/v2/rest"
        return self._api_base

    def _url(self, path: str) -> str:
        base = self._detect_api()
        return f"{base}{path}"

    def _do_request(
        self,
        method: str,
        url: str,
        *,
        params: dict | None = None,
        json: dict | None = None,
        timeout: int = 15,
    ) -> requests.Response:
        """Execute an HTTP request with automatic retry on 429 and connection errors.

        Uses the ``Retry-After`` header when present; falls back to
        exponential back-off (1s, 2s, 4s, 8s, 16s).
        """
        for attempt in range(self.MAX_RETRIES + 1):
            try:
                resp = self.session.request(
                    method, url, params=params, json=json, timeout=timeout,
                )
            except (requests.ConnectionError, requests.Timeout) as exc:
                if attempt == self.MAX_RETRIES:
                    raise
                wait = self.BASE_DELAY * (2 ** attempt)
                logger.warning(
                    "[Troi MCP] %s on %s %s. Retry in %.1fs (%d/%d)",
                    type(exc).__name__, method, url, wait,
                    attempt + 1, self.MAX_RETRIES,
                )
                time.sleep(wait)
                continue

            if resp.status_code != 429:
                return resp

            # Rate limited
            if attempt == self.MAX_RETRIES:
                raise TroiError(
                    429,
                    f"Rate limit exceeded after {self.MAX_RETRIES} retries. "
                    "Bitte in 30-60 Sekunden erneut versuchen.",
                    url,
                )

            retry_after = resp.headers.get("Retry-After")
            wait = (
                float(retry_after)
                if retry_after
                else self.BASE_DELAY * (2 ** attempt)
            )
            logger.warning(
                "[Troi MCP] 429 rate limit on %s %s. Retry in %.1fs (%d/%d)",
                method, url, wait, attempt + 1, self.MAX_RETRIES,
            )
            time.sleep(wait)

        raise TroiError(429, "Rate limit exceeded", url)  # pragma: no cover

    def _get(self, path: str, params: dict | None = None) -> Any:
        url = self._url(path)
        clean_params = {k: v for k, v in (params or {}).items() if v is not None}
        r = self._do_request("GET", url, params=clean_params)
        return self._handle(r, url)

    def _post(self, path: str, data: dict) -> Any:
        url = self._url(path)
        r = self._do_request("POST", url, json=data)
        return self._handle(r, url)

    def _put(self, path: str, data: dict) -> Any:
        url = self._url(path)
        r = self._do_request("PUT", url, json=data)
        return self._handle(r, url)

    def _delete(self, path: str) -> Any:
        url = self._url(path)
        r = self._do_request("DELETE", url)
        return self._handle(r, url)

    def _handle(self, r: requests.Response, url: str) -> Any:
        if r.ok:
            try:
                return r.json()
            except ValueError:
                return {"status": "ok"}

        body = r.text[:500]
        if r.status_code == 401:
            raise TroiError(401, "Authentication failed – check TROI_USER and TROI_API_KEY", url, body)
        elif r.status_code == 403:
            raise TroiError(403, "Forbidden – API key lacks required permissions", url, body)
        elif r.status_code == 404:
            raise TroiError(404, f"Not found: {url}", url, body)
        else:
            raise TroiError(r.status_code, f"HTTP {r.status_code}", url, body)

    # ------------------------------------------------------------------
    # Clients
    # ------------------------------------------------------------------

    def list_clients(self) -> list[dict]:
        return self._get("/clients")

    def get_client(self, client_id: int) -> dict:
        return self._get(f"/clients/{client_id}")

    # ------------------------------------------------------------------
    # Projects (REST API) / Calculation Objects (Legacy API)
    # ------------------------------------------------------------------

    def list_projects(
        self,
        *,
        search: str | None = None,
        customer_id: int | None = None,
        in_process: bool | None = None,
        project_status_id: int | None = None,
        project_type_id: int | None = None,
        project_leader_id: int | None = None,
        size: int = 200,
    ) -> list[dict]:
        base = self._detect_api()
        if "rest" in base:
            params: dict[str, Any] = {"clientId": self.client_id, "size": size}
            if search:
                params["search"] = search
            if customer_id:
                params["customerId"] = customer_id
            if in_process is not None:
                params["projectIsInProcess"] = str(in_process).lower()
            if project_status_id:
                params["projectStatusId"] = project_status_id
            if project_type_id:
                params["projectTypeId"] = project_type_id
            if project_leader_id:
                params["projectLeaderId"] = project_leader_id
            return self._get("/projects", params)
        else:
            # Legacy API
            params = {
                "clientId": self.client_id,
                "type": "project",
                "limit": size,
            }
            if search:
                params["name"] = search
            if in_process is False:
                params["isArchived"] = "true"
            else:
                params["isArchived"] = "false"
            return self._get("/calculationObjects", params)

    def get_project(self, project_id: int) -> dict:
        base = self._detect_api()
        if "rest" in base:
            return self._get(f"/projects/{project_id}")
        else:
            return self._get(f"/calculationObjects/{project_id}")

    def create_project(self, data: dict) -> dict:
        """Create a project via the internal component API.

        Accepts a dict with keys matching the component endpoint format:
          name (str or {en, de, fr}), customer (str hash), projectLeader (int),
          projectType (str), reportingDate (epoch-ms or YYYY-MM-DD),
          projectStartDate, projectEndDate, etc.

        If ``name`` is a plain string it is auto-wrapped into {en, de, fr}.
        Dates given as YYYY-MM-DD are converted to epoch milliseconds.
        ``client`` defaults to self.client_id.
        """
        payload = dict(data)

        # Normalise name
        name = payload.get("name", payload.pop("Name", ""))
        if isinstance(name, str):
            payload["name"] = {"en": name, "de": name, "fr": name}

        # Defaults
        payload.setdefault("client", int(self.client_id))
        payload.setdefault("calculationType", 1)
        payload.setdefault("projectType", "28")  # Custom Projekt
        payload.setdefault("inFavorites", False)
        payload.setdefault("projectFolder", None)
        payload.setdefault("projectTaskEndDate", None)
        payload.setdefault("projectTaskStartDate", None)
        payload.setdefault("project_number_part_customer", "automatic")
        payload.setdefault("project_number_part_date", "automatic")
        payload.setdefault("project_number_part_sequence", "automatic")

        # Convert YYYY-MM-DD dates to epoch-ms
        for key in ("reportingDate", "projectStartDate", "projectEndDate"):
            val = payload.get(key)
            if isinstance(val, str) and re.match(r"\d{4}-\d{2}-\d{2}", val):
                from datetime import datetime, timezone
                dt = datetime.strptime(val[:10], "%Y-%m-%d").replace(
                    tzinfo=timezone.utc
                )
                payload[key] = int(dt.timestamp() * 1000)

        # Default start/end/reporting to now / +30 days
        now_ms = int(time.time() * 1000)
        end_ms = now_ms + 30 * 24 * 3600 * 1000
        payload.setdefault("reportingDate", now_ms)
        payload.setdefault("projectStartDate", now_ms)
        payload.setdefault("projectEndDate", end_ms)

        result = self._component_post("/components/projectlist/item", payload)

        # The component API wraps the result; unwrap to a flat project dict
        if isinstance(result, dict) and "data" in result:
            item = result["data"].get("item", result["data"])
            # Fetch the full project via REST for a consistent return shape
            proj_id = None
            if isinstance(item.get("id"), dict):
                proj_id = item["id"].get("id")
            elif isinstance(item.get("id"), int):
                proj_id = item["id"]
            if proj_id:
                try:
                    return self.get_project(proj_id)
                except TroiError:
                    pass
            return item
        return result

    def update_project(self, project_id: int, data: dict) -> dict:
        return self._put(f"/projects/{project_id}", data)

    def delete_project(self, project_id: int) -> dict:
        """Delete a project.  Tries the component API first, REST as fallback."""
        try:
            return self._component_delete(
                f"/components/projectlist/item/{project_id}"
            )
        except TroiError:
            return self._delete(f"/projects/{project_id}")

    # ------------------------------------------------------------------
    # Employees
    # ------------------------------------------------------------------

    def list_employees(self, *, show_inactive: bool = False) -> list[dict]:
        params = {"clientId": self.client_id}
        if show_inactive:
            params["showInactive"] = "true"
        return self._get("/employees", params)

    def get_employee(self, employee_id: int) -> dict:
        return self._get(f"/employees/{employee_id}")

    # ------------------------------------------------------------------
    # Customers
    # ------------------------------------------------------------------

    def list_customers(self, *, is_active: bool | None = None) -> list[dict]:
        params: dict[str, Any] = {"clientId": self.client_id}
        if is_active is not None:
            params["isActive"] = str(is_active).lower()
        return self._get("/customers", params)

    def get_customer(self, customer_id: int) -> dict:
        return self._get(f"/customers/{customer_id}")

    def create_customer(
        self,
        *,
        name: str,
        contact_id: int,
        shortcut: str,
        is_active: bool = True,
        payment_term_id: int | None = None,
        vat_number: str | None = None,
        tax_number: str | None = None,
    ) -> dict:
        """Create a customer (Auftraggeber) in Troi.

        Args:
            name: Customer name (usually = company or department name).
            contact_id: Contact:Company ID to link (company or department).
            shortcut: 3-letter uppercase abbreviation (e.g. "MER").
                Used as prefix for project numbers.
            is_active: Whether customer is active (default True).
            payment_term_id: Payment term ID (e.g. for 14-day terms).
            vat_number: VAT ID (USt-ID).
            tax_number: Tax number (Steuernummer).

        Returns:
            dict with customer_id (hash string), name, shortcut.
        """
        payload: dict[str, Any] = {
            "Name": name,
            "Shortcut": shortcut.upper(),
            "Contact": {"Path": f"/contacts/{contact_id}", "Id": contact_id},
            "Client": {"Path": f"/clients/{self.client_id}", "Id": int(self.client_id)},
            "IsActive": is_active,
            "Number": "",  # Required by Troi API — empty string, Troi auto-generates
            # Default payment terms: 14 days net
            "PaymentTerm": {
                "NetDays": "14",
                "DiscountDays1": "0",
                "DiscountRate1": "0,00",
                "DiscountDays2": "0",
                "DiscountRate2": "0,00",
                "DiscountDays3": "0",
                "DiscountRate3": "0,00",
                "DiscountDays4": "0",
                "DiscountRate4": "0,00",
            },
        }

        if payment_term_id is not None:
            payload["PaymentTerm"] = {"Path": f"/misc/paymentTerms/{payment_term_id}"}
        if vat_number:
            payload["VatNumber"] = vat_number
        if tax_number:
            payload["TaxNumber"] = tax_number

        result = self._post("/customers", payload)

        # Customer IDs are MD5 hash strings
        customer_id = result.get("Id") or result.get("id")

        return {
            "customer_id": customer_id,
            "name": name,
            "shortcut": shortcut.upper(),
            "contact_id": contact_id,
        }

    def update_customer(self, customer_id: str, data: dict) -> dict:
        """Update an existing customer.

        Args:
            customer_id: Customer hash ID (MD5 string).
            data: Dict with fields to update. Supported keys:
                name, shortcut, is_active, contact_id, net_days,
                payment_term_id, vat_number, tax_number.

        Returns:
            API response dict.
        """
        payload: dict[str, Any] = {}

        if "name" in data:
            payload["Name"] = data["name"]
        if "shortcut" in data:
            payload["Shortcut"] = data["shortcut"].upper()
        if "is_active" in data:
            payload["IsActive"] = data["is_active"]
        if "contact_id" in data:
            cid = data["contact_id"]
            payload["Contact"] = {"Path": f"/contacts/{cid}", "Id": cid}
        if "net_days" in data:
            payload["PaymentTerm"] = {
                "NetDays": str(data["net_days"]),
                "DiscountDays1": "0",
                "DiscountRate1": "0,00",
                "DiscountDays2": "0",
                "DiscountRate2": "0,00",
                "DiscountDays3": "0",
                "DiscountRate3": "0,00",
                "DiscountDays4": "0",
                "DiscountRate4": "0,00",
            }
        if "payment_term_id" in data:
            payload["PaymentTerm"] = {"Path": f"/misc/paymentTerms/{data['payment_term_id']}"}
        if "vat_number" in data:
            payload["VatNumber"] = data["vat_number"]
        if "tax_number" in data:
            payload["TaxNumber"] = data["tax_number"]

        if not payload:
            return {"status": "no_changes", "customer_id": customer_id}

        return self._put(f"/customers/{customer_id}", payload)

    def create_customer_with_contact(self, spec: dict) -> dict:
        """Create a customer with full contact hierarchy in one call.

        Supports two variants:
        - Variante A (standard): Company + optional Person + Customer
        - Variante B (corporate): existing Company + Department + optional Person + Customer

        Includes duplicate checking for companies and departments.

        Args:
            spec: Dict with keys:
                company_name (str, required): Company name to find or create.
                shortcut (str, required): 3-letter customer abbreviation.
                company_id (int, optional): Skip company search, use this ID.
                create_company_if_missing (bool, default True): Create company
                    if not found. Set False for corporate variant.
                department_name (str, optional): Department name (triggers
                    corporate variant).
                department_street/zip_code/city/country_iso: Dept address.
                street/zip_code/city/country_iso: Company address (for new
                    companies).
                company_email/phone/website: Company vCard contact fields.
                is_active (bool, default True): Customer active flag.
                payment_term_id (int, optional): Payment term ID.
                contact_first_name/last_name/email/phone/mobile/position/
                    salutation: Contact person fields.

        Returns:
            dict with customer_id, company_contact_id, department_contact_id,
            person_contact_id, and created flags.
        """
        company_name = spec["company_name"]
        shortcut = spec["shortcut"]
        company_id = spec.get("company_id")
        create_if_missing = spec.get("create_company_if_missing", True)
        department_name = spec.get("department_name")
        errors: list[str] = []

        # --- 1. Find or create company ---
        company_created = False
        if company_id:
            # Direct ID provided — skip search
            pass
        else:
            results = self.list_contacts(search=company_name)
            # Filter: only top-level companies (Parent=null)
            companies = []
            for c in results:
                if c.get("Type") != "ApiContact:Company":
                    continue
                parent = c.get("Parent")
                if parent is not None and parent != 0:
                    continue
                companies.append(c)

            if len(companies) == 1:
                company_id = companies[0].get("id")
            elif len(companies) == 0:
                if create_if_missing:
                    try:
                        result = self.create_contact(
                            name=company_name,
                            contact_type="company",
                            street=spec.get("street"),
                            zip_code=spec.get("zip_code"),
                            city=spec.get("city"),
                            country_iso=spec.get("country_iso", "DE"),
                            email=spec.get("company_email"),
                            phone=spec.get("company_phone"),
                            website=spec.get("company_website"),
                        )
                        company_id = result["contact_id"]
                        company_created = True
                    except TroiError as e:
                        raise TroiError(
                            500,
                            f"Failed to create company '{company_name}': {e}",
                            "",
                        )
                else:
                    raise TroiError(
                        404,
                        f"Company '{company_name}' not found. "
                        f"Provide company_id or set create_company_if_missing=true.",
                        "",
                    )
            else:
                ids_list = [(c.get("id"), c.get("Name2")) for c in companies]
                raise TroiError(
                    409,
                    f"Multiple companies found for '{company_name}': {ids_list}. "
                    f"Please specify company_id.",
                    "",
                )

        # --- 2. Create department (if specified) ---
        department_id = None
        if department_name:
            # Duplicate check
            dept_results = self.list_contacts(search=department_name)
            existing_depts = [
                d for d in dept_results
                if d.get("Type") == "ApiContact:Company"
                and d.get("Parent")
                and (
                    (isinstance(d["Parent"], dict) and d["Parent"].get("id") == company_id)
                    or d["Parent"] == company_id
                )
                and d.get("Name2") == department_name
            ]
            if existing_depts:
                raise TroiError(
                    409,
                    f"Department '{department_name}' already exists under company "
                    f"(Contact-ID: {existing_depts[0].get('id')}). "
                    f"Use the existing one or choose a different name.",
                    "",
                )

            try:
                dept_result = self.create_contact(
                    name=department_name,
                    contact_type="company",
                    parent_contact_id=company_id,
                    street=spec.get("department_street"),
                    zip_code=spec.get("department_zip_code"),
                    city=spec.get("department_city"),
                    country_iso=spec.get("department_country_iso", "DE"),
                )
                department_id = dept_result["contact_id"]
            except TroiError as e:
                errors.append(f"Department creation failed: {e}")

        # --- 3. Create contact person (if last_name provided) ---
        person_id = None
        person_name = None
        contact_last_name = spec.get("contact_last_name")
        if contact_last_name:
            parent_id = department_id if department_id else company_id
            try:
                person_result = self.create_contact(
                    name=contact_last_name,
                    contact_type="person",
                    first_name=spec.get("contact_first_name"),
                    parent_contact_id=parent_id,
                    email=spec.get("contact_email"),
                    phone=spec.get("contact_phone"),
                    mobile=spec.get("contact_mobile"),
                    position=spec.get("contact_position"),
                    salutation=spec.get("contact_salutation"),
                )
                person_id = person_result["contact_id"]
                person_name = person_result["name"]
            except TroiError as e:
                errors.append(f"Contact person creation failed: {e}")

        # --- 4. Create customer ---
        customer_contact_id = department_id if department_id else company_id
        customer_name = department_name if department_name else company_name

        try:
            cust_result = self.create_customer(
                name=customer_name,
                contact_id=customer_contact_id,
                shortcut=shortcut,
                is_active=spec.get("is_active", True),
                payment_term_id=spec.get("payment_term_id"),
            )
            customer_id = cust_result["customer_id"]
        except TroiError as e:
            raise TroiError(
                500,
                f"Customer creation failed: {e}. "
                f"Company ID: {company_id}, Department ID: {department_id}",
                "",
            )

        result = {
            "customer_id": customer_id,
            "customer_name": customer_name,
            "customer_shortcut": shortcut.upper(),
            "company_contact_id": company_id,
            "company_name": company_name,
            "company_created": company_created,
            "department_contact_id": department_id,
            "department_name": department_name,
            "person_contact_id": person_id,
            "person_name": person_name,
        }
        if errors:
            result["errors"] = errors

        return result

    # ------------------------------------------------------------------
    # Calculation Positions (project sub-items / service lines)
    # ------------------------------------------------------------------

    @staticmethod
    def _enrich_position(pos: dict) -> dict:
        """Compute TotalCalculation when the API returns 0 but Quantity and SalePrice are set."""
        tc = float(pos.get("TotalCalculation", 0) or 0)
        if tc == 0:
            qty = pos.get("Quantity", 0) or 0
            sp = float(pos.get("SalePrice", 0) or 0)
            if qty and sp:
                pos["TotalCalculation"] = float(qty) * sp
        return pos

    def list_calculation_positions(
        self,
        *,
        project_id: int | None = None,
        search: str | None = None,
        favorites_only: bool = False,
    ) -> list[dict]:
        params: dict[str, Any] = {"clientId": self.client_id}
        if project_id:
            params["projectId"] = project_id
        if search:
            params["search"] = search
        if favorites_only:
            params["favoritesOnly"] = "true"
        positions = self._get("/calculationPositions", params)
        return [self._enrich_position(p) for p in positions]

    def get_calculation_position(self, position_id: int) -> dict:
        pos = self._get(f"/calculationPositions/{position_id}")
        return self._enrich_position(pos)

    def create_calculation_position(self, data: dict) -> dict:
        """Create a calculation position via REST POST (may return 500).

        Prefer create_calculation_position_from_pricelist() which uses the
        internal addEntries endpoint and works reliably.
        """
        return self._post("/calculationPositions", data)

    def update_calculation_position(self, position_id: int, data: dict) -> dict:
        return self._put(f"/calculationPositions/{position_id}", data)

    # ------------------------------------------------------------------
    # Subprojects
    # ------------------------------------------------------------------

    def list_subprojects(
        self,
        *,
        project_id: int | None = None,
    ) -> list[dict]:
        params: dict[str, Any] = {"clientId": self.client_id}
        if project_id:
            params["projectId"] = project_id
        return self._get("/subprojects", params)

    def get_subproject(self, subproject_id: int) -> dict:
        return self._get(f"/subprojects/{subproject_id}")

    def create_subproject(self, project_id: int, name: str, project_type_id: int = 28) -> dict:
        """Create a subproject via the legacy web UI form POST.

        The v2 REST API POST /subprojects returns 500, so we use the same
        form endpoint the web UI uses: POST /site/index.php with
        page=subproject&action=add.

        After creation, fetches the new subproject via REST to return a
        consistent dict with ``id``.
        """
        # Get subprojects before creation to diff afterwards
        before = {s["id"] for s in self.list_subprojects(project_id=project_id)}

        form_data = {
            "page": "subproject",
            "action": "add",
            "retainer_type": "",
            "project": str(project_id),
            "projectplan": "",
            "subproject": "",
            "back_page": "",
            "back_url": "",
            "subproject_submit": "save",
            "sheet": "",
            # Slot 1 — the subproject we want to create
            "subproject_name[1]": name,
            "subproject_ct[1]": "0",
            "saving_project_types": "false",
            f"project_type[1][{project_type_id}]": "1",
            # Empty slots 2 and 3 (the form always sends 3 slots)
            "subproject_name[2]": "",
            "subproject_ct[2]": "0",
            f"project_type[2][{project_type_id}]": "1",
            "subproject_name[3]": "",
            "subproject_ct[3]": "0",
            f"project_type[3][{project_type_id}]": "1",
        }
        # The form sends "saving_project_types" three times (once per slot)
        # requests doesn't support duplicate keys in a dict, so use a list of tuples
        form_list = []
        for k, v in form_data.items():
            if k == "saving_project_types":
                continue  # we'll add these manually
            form_list.append((k, v))
        # Insert saving_project_types before each project_type slot
        final_form: list[tuple[str, str]] = []
        for k, v in form_list:
            if k.startswith("project_type["):
                final_form.append(("saving_project_types", "false"))
            final_form.append((k, v))

        self._form_post("/site/index.php", final_form)

        # Find the newly created subproject by diffing
        after = self.list_subprojects(project_id=project_id)
        new_subs = [s for s in after if s["id"] not in before]

        if new_subs:
            return new_subs[0]

        # Fallback: return the last subproject in the list
        if after:
            return after[-1]
        return {"status": "created", "project_id": project_id, "name": name}

    def update_subproject(self, subproject_id: int, data: dict) -> dict:
        return self._put(f"/subprojects/{subproject_id}", data)

    def delete_subproject(self, subproject_id: int, project_id: int | None = None) -> dict:
        """Delete a subproject via the legacy web UI form POST.

        The v2 REST API DELETE /subprojects/{id} returns 500, so we use:
        POST /site/index.php with page=subproject&action=remove.

        ``project_id`` is optional; if not provided the subproject is fetched
        first to determine its parent project.
        """
        if not project_id:
            sub = self.get_subproject(subproject_id)
            proj_ref = sub.get("Project", {})
            if isinstance(proj_ref, dict) and "Path" in proj_ref:
                project_id = int(proj_ref["Path"].rsplit("/", 1)[-1])
            elif isinstance(proj_ref, dict) and "id" in proj_ref:
                project_id = proj_ref["id"]

        form_data = {
            "page": "subproject",
            "action": "remove",
            "project": str(project_id or ""),
            "subproject": str(subproject_id),
        }
        try:
            self._form_post("/site/index.php", form_data)
        except TroiError:
            pass  # Form endpoint sometimes returns 500 even on success

        # Verify deletion
        try:
            self.get_subproject(subproject_id)
            # Still exists — try REST API as last resort
            return self._delete(f"/subprojects/{subproject_id}")
        except TroiError as e:
            if e.status_code == 404:
                return {"status": "deleted", "id": subproject_id}
            raise

    # ------------------------------------------------------------------
    # Price List (component API — used for creating calculation positions)
    # ------------------------------------------------------------------

    def _component_get(self, path: str, params: dict | None = None) -> Any:
        """GET from an internal /components/… endpoint."""
        self._ensure_session()
        url = f"{self.base_url}{path}"
        r = self.session.get(url, params=params or {}, timeout=15)
        return self._handle(r, url)

    def list_pricelist_groups(self) -> list[dict]:
        """List price list groups (Preislisten).

        Returns a list of groups, each with uuid, name, and metadata.
        Use the uuid to fetch items from a specific group.
        """
        result = self._component_get(
            "/components/pricelist/group/list",
            {"client": self.client_id, "withInactive": "false"},
        )
        if isinstance(result, dict) and "data" in result:
            return result["data"]
        if isinstance(result, list):
            return result
        return [result] if result else []

    def list_pricelist_items(self, group_id: str | None = None) -> list[dict]:
        """Fetch all items from a price list group.

        Returns a flat list of items with uuid, name, price, unit, etc.
        If group_id is not provided, uses the first active group.
        """
        if not group_id:
            groups = self.list_pricelist_groups()
            if not groups:
                raise TroiError(404, "No price list groups found", "", "")
            group_id = groups[0].get("uuid") or groups[0].get("id")

        tree = self._component_get(
            "/components/pricelist/get/search",
            {"client": self.client_id, "group": group_id, "lang": "de"},
        )

        # The tree is nested: root → folders → items
        # Structure: {type: "collection", data: {type: "folder", children: [...]}}
        raw = tree
        if isinstance(tree, dict) and "data" in tree:
            raw = tree["data"]
        if isinstance(raw, dict):
            raw = [raw]

        items: list[dict] = []
        self._flatten_pricelist(raw, items)
        return items

    @staticmethod
    def _extract_node_id(node: dict) -> str | None:
        """Extract the UUID from a price list node's id field."""
        node_id = node.get("id")
        if isinstance(node_id, dict):
            return node_id.get("id")  # {id: "uuid-...", client: 1, ...}
        if isinstance(node_id, str):
            return node_id
        return None

    @staticmethod
    def _extract_name(node: dict, lang: str = "de") -> str:
        """Extract localized name from fields.name or direct name."""
        fields = node.get("fields", {})
        name_obj = fields.get("name", node.get("name", ""))
        if isinstance(name_obj, dict):
            return name_obj.get(lang, name_obj.get("de", name_obj.get("en", "")))
        return str(name_obj) if name_obj else ""

    def _flatten_pricelist(self, nodes: list, out: list[dict], folder_name: str = "") -> None:
        """Recursively flatten a price list tree into items."""
        for node in nodes:
            if not isinstance(node, dict):
                continue
            node_type = node.get("type", "")
            children = node.get("children", [])
            name = self._extract_name(node)
            node_uuid = self._extract_node_id(node)

            if node_type == "folder" or children:
                label = name or folder_name
                self._flatten_pricelist(children, out, label)
            elif node_type == "item" and node_uuid:
                fields = node.get("fields", {})
                desc = fields.get("description", {})
                item = {
                    "uuid": node_uuid,
                    "name": name,
                    "folder": folder_name,
                    "salePrice": fields.get("fee"),
                    "unit": fields.get("unit"),
                    "account": fields.get("account"),
                    "active": fields.get("active", True),
                    "description": desc.get("de", "") if isinstance(desc, dict) else str(desc or ""),
                }
                out.append(item)

    def _build_pricelist_structure(self, tree_data: Any) -> list[dict]:
        """Build the flat structure list required by addEntries from a price list tree."""
        raw = tree_data
        if isinstance(raw, dict) and "data" in raw:
            raw = raw["data"]
        if isinstance(raw, dict):
            raw = [raw]

        structure: list[dict] = []
        self._walk_pricelist_structure(raw, structure, parent="")
        return structure

    def _walk_pricelist_structure(
        self, nodes: list, out: list[dict], parent: str
    ) -> None:
        """Walk the price list tree and build the addEntries structure."""
        for node in nodes:
            if not isinstance(node, dict):
                continue
            node_uuid = self._extract_node_id(node)
            children = node.get("children", [])
            node_type = node.get("type", "item" if not children else "folder")

            # Skip root node (id is null/None)
            if node_uuid is None:
                self._walk_pricelist_structure(children, out, parent="")
                continue

            if children or node_type == "folder":
                out.append({"type": "folder", "id": str(node_uuid), "parent": parent})
                self._walk_pricelist_structure(children, out, parent=str(node_uuid))
            else:
                out.append({"type": "item", "id": str(node_uuid), "parent": parent})

    def create_calculation_position_from_pricelist(
        self,
        project_id: int,
        subproject_id: int,
        item_uuid: str,
        group_id: str | None = None,
        currency: int = 1,
        quantity: float | int = 1,
        sale_price_override: float | None = None,
    ) -> dict:
        """Create a calculation position by adding a price list item to a project.

        This uses the internal addEntries endpoint which is the same mechanism
        the Troi web UI uses when adding items from the Preisliste.

        Args:
            project_id: Target project ID
            subproject_id: Target subproject ID
            item_uuid: UUID of the price list item to add
            group_id: Price list group UUID (auto-detected if not given)
            currency: Currency ID (default 1 = EUR)
            quantity: Quantity to set on the position (default 1)
            sale_price_override: Override the price list sale price (optional)

        Returns the newly created calculation position dict.
        """
        if not group_id:
            groups = self.list_pricelist_groups()
            if not groups:
                raise TroiError(404, "No price list groups found", "", "")
            group_id = groups[0].get("uuid") or groups[0].get("id")

        # Fetch the full tree to build the structure
        tree = self._component_get(
            "/components/pricelist/get/search",
            {"client": self.client_id, "group": group_id, "lang": "de"},
        )
        structure = self._build_pricelist_structure(tree)

        # Get positions before to diff afterwards
        before = {
            p["id"]
            for p in self.list_calculation_positions(project_id=project_id)
        }

        # Call addEntries
        payload = {
            "project": project_id,
            "subProject": subproject_id,
            "currency": currency,
            "folders": [],
            "items": [item_uuid],
            "structure": structure,
        }
        self._component_post("/components/pricelist/legacy/addEntries", payload)

        # Find the newly created position by diffing
        after = self.list_calculation_positions(project_id=project_id)
        new_positions = [p for p in after if p["id"] not in before]

        position = new_positions[0] if new_positions else (after[-1] if after else None)

        # Update quantity and/or sale price on the new position
        if position and position.get("id"):
            update_data: dict[str, Any] = {"Quantity": quantity}
            if sale_price_override is not None:
                update_data["SalePrice"] = sale_price_override
            # Component API doesn't auto-calculate totals — set them explicitly
            effective_price = (
                sale_price_override
                if sale_price_override is not None
                else float(position.get("SalePrice", 0) or 0)
            )
            total = float(quantity) * effective_price
            update_data["TotalCalculation"] = total
            update_data["TotalOffer"] = total
            self.update_calculation_position(position["id"], update_data)
            # Re-fetch full position (PUT returns sparse ApiSyncItem)
            position = self.get_calculation_position(position["id"])

        if position:
            return position
        return {"status": "created", "project_id": project_id, "item_uuid": item_uuid}

    def _sync_document_positions(
        self,
        project_id: int,
        subproject_ids: list[int],
        cp_ids: list[int],
    ) -> dict:
        """Sync CalculationPositions to DocumentPositions via Troi's web UI forms.

        The REST API cannot create/update DocumentPositions directly.
        This uses the same form POST mechanism as the Troi web UI:
        1. save_preparation — creates a new document with DocumentPositions
        2. save_print_option — sets print options (cp_po=3 = 'alles')
        """
        # Find the auto-created offer document
        docs = self.list_documents(project_id=project_id)
        offer_docs = [d for d in docs if d.get("DocumentType") == "O"]
        if not offer_docs:
            return {"sync_status": "failed", "error": "No offer document found"}

        original_doc_id = offer_docs[-1].get("id")
        if not original_doc_id:
            return {"sync_status": "failed", "error": "Document has no ID"}

        # Step 1: save_preparation — creates DocumentPositions in a new document
        prep_data: list[tuple[str, str]] = [
            ("page", "project_offer"),
            ("action", "save_preparation"),
            ("offer_view", "preparation"),
            ("project", str(project_id)),
            ("document", str(original_doc_id)),
        ]
        for sp_id in subproject_ids:
            prep_data.append(("subprojects[]", str(sp_id)))
        for cp_id in cp_ids:
            prep_data.append(("cps[]", str(cp_id)))
            prep_data.append((f"cp_{cp_id}", "1"))

        resp = self._form_post("/site/index.php", prep_data)
        # Detect silent failure (redirect to login page)
        if resp.url and "login.php" in resp.url:
            return {"sync_status": "failed", "error": "save_preparation redirected to login (session issue)"}

        # Find the newly created document
        docs_after = self.list_documents(project_id=project_id)
        offer_docs_after = sorted(
            [d for d in docs_after if d.get("DocumentType") == "O"],
            key=lambda d: d.get("id", 0),
        )
        new_doc_id = original_doc_id
        for d in offer_docs_after:
            if d.get("id") != original_doc_id:
                new_doc_id = d["id"]

        # Step 2: save_print_option — set print controls for each position
        print_data: list[tuple[str, str]] = [
            ("page", "project_offer"),
            ("action", "save_print_option"),
            ("project", str(project_id)),
            ("document", str(new_doc_id)),
        ]
        for sp_id in subproject_ids:
            print_data.append((f"subproject_printable[{sp_id}]", "on"))
        for cp_id in cp_ids:
            print_data.append((f"cp_printable[{cp_id}]", "on"))
            print_data.append((f"cp_po[{cp_id}]", "3"))  # 3 = "alles"

        resp2 = self._form_post("/site/index.php", print_data)
        if resp2.url and "login.php" in resp2.url:
            return {"sync_status": "failed", "error": "save_print_option redirected to login (session issue)"}

        return {
            "sync_status": "ok",
            "document_id": new_doc_id,
            "original_document_id": original_doc_id,
        }

    def create_offer(self, spec: dict) -> dict:
        """Create a complete offer (project + phases + positions) in one call.

        Args:
            spec: Offer specification dict with keys:
                name (str, required): Project/offer name
                customer (str, required): Customer hash ID
                project_type (int|str): Project type ID (default 28)
                status_id (int): Project status ID (default 55 = angeboten)
                internal_description (str): Internal notes
                external_description (str): Client-facing description
                reporting_date (str): YYYY-MM-DD
                start_date (str): YYYY-MM-DD
                end_date (str): YYYY-MM-DD
                project_leader (int): Employee ID (required, shown as 'AirLST:' on PDF)
                contact_id (int): Contact person ID (shown as 'Ansprechpartner' on PDF)
                phases (list): List of phase dicts, each with:
                    name (str): Phase/subproject name
                    positions (list): List of position dicts, each with:
                        pricelist_item_uuid (str): Price list item UUID
                        quantity (float|int): Quantity (default 1)
                        sale_price_override (float): Override price (optional)

        Returns:
            dict with project info, phase/position IDs, and offer summary.
        """
        errors: list[str] = []

        # --- 1. Create project ---
        proj_data: dict[str, Any] = {
            "name": spec["name"],
            "customer": spec["customer"],
        }
        if spec.get("project_type"):
            proj_data["projectType"] = str(spec["project_type"])
        if spec.get("project_leader"):
            proj_data["projectLeader"] = spec["project_leader"]
        for key, api_key in [
            ("reporting_date", "reportingDate"),
            ("start_date", "projectStartDate"),
            ("end_date", "projectEndDate"),
        ]:
            if spec.get(key):
                proj_data[api_key] = spec[key]

        project = self.create_project(proj_data)
        project_id = project.get("id")
        if not project_id:
            raise TroiError(500, "Project creation failed — no ID returned", "", str(project))

        # Set status + descriptions via REST PUT
        post_update: dict[str, Any] = {}
        status_id = spec.get("status_id", 55)
        post_update["Status"] = {"Path": f"/misc/projectStatuses/{status_id}"}
        if spec.get("external_description"):
            post_update["ExternalDescription"] = spec["external_description"]
        if spec.get("internal_description"):
            post_update["InternalDescription"] = spec["internal_description"]
        try:
            self.update_project(project_id, post_update)
        except TroiError as e:
            errors.append(f"Status/description update failed: {e}")

        # --- 2. Create phases (subprojects) + positions ---
        phases_result: list[dict] = []
        all_subproject_ids: list[int] = []
        all_cp_ids: list[int] = []
        # Pre-fetch pricelist tree once for all positions
        group_id = None
        groups = self.list_pricelist_groups()
        if groups:
            group_id = groups[0].get("uuid") or groups[0].get("id")

        for phase_spec in spec.get("phases", []):
            phase_name = phase_spec.get("name", "Unnamed Phase")

            try:
                subproject = self.create_subproject(project_id, phase_name)
                subproject_id = subproject.get("id")
            except TroiError as e:
                errors.append(f"Phase '{phase_name}' creation failed: {e}")
                phases_result.append({"name": phase_name, "error": str(e), "positions": []})
                continue

            if subproject_id:
                all_subproject_ids.append(subproject_id)

            positions_result: list[dict] = []
            for pos_spec in phase_spec.get("positions", []):
                item_uuid = pos_spec.get("pricelist_item_uuid")
                qty = pos_spec.get("quantity", 1)
                sp_override = pos_spec.get("sale_price_override")

                try:
                    pos = self.create_calculation_position_from_pricelist(
                        project_id=project_id,
                        subproject_id=subproject_id,
                        item_uuid=item_uuid,
                        group_id=group_id,
                        quantity=qty,
                        sale_price_override=sp_override,
                    )
                    pos_id = pos.get("id")
                    if pos_id:
                        all_cp_ids.append(pos_id)
                    qty_val = pos.get("Quantity", 0) or 0
                    sp_val = float(pos.get("SalePrice", 0) or 0)
                    tc_val = float(pos.get("TotalCalculation", 0) or 0)
                    line_total = tc_val if tc_val else float(qty_val) * sp_val
                    positions_result.append({
                        "id": pos_id,
                        "name": pos.get("Name", ""),
                        "quantity": qty_val,
                        "sale_price": sp_val,
                        "total": line_total,
                    })
                except TroiError as e:
                    errors.append(f"Position '{item_uuid}' in '{phase_name}' failed: {e}")
                    positions_result.append({"item_uuid": item_uuid, "error": str(e)})

            phases_result.append({
                "name": phase_name,
                "subproject_id": subproject_id,
                "positions": positions_result,
            })

        # --- 2b. Sync DocumentPositions (save_preparation + save_print_option) ---
        synced_doc_id: int | None = None
        original_doc_id: int | None = None
        if all_cp_ids and all_subproject_ids:
            try:
                sync_result = self._sync_document_positions(
                    project_id, all_subproject_ids, all_cp_ids,
                )
                if sync_result.get("sync_status") == "ok":
                    synced_doc_id = sync_result.get("document_id")
                    original_doc_id = sync_result.get("original_document_id")
                else:
                    errors.append(f"Document sync failed: {sync_result.get('error', 'unknown')}")
            except Exception as e:
                errors.append(f"Document sync failed: {e}")

        # --- 2c. Update document metadata (Signee via REST + Contact via form POST) ---
        if synced_doc_id:
            # Signee via REST PUT (this one actually works)
            if spec.get("project_leader"):
                try:
                    self._put(f"/documents/{synced_doc_id}", {
                        "Signee": {"Path": f"/employees/{spec['project_leader']}"},
                    })
                except Exception as e:
                    errors.append(f"Document Signee update failed: {e}")

            # Contact + date via form POST (REST API silently ignores Contact)
            if spec.get("contact_id") or spec.get("reporting_date"):
                reporting_date_for_doc = spec.get("reporting_date")
                if not reporting_date_for_doc:
                    try:
                        proj = self.get_project(project_id)
                        reporting_date_for_doc = proj.get("ReportingDate")
                    except Exception:
                        pass
                try:
                    self._save_document_metadata(
                        project_id,
                        synced_doc_id,
                        contact_id=spec.get("contact_id"),
                        reporting_date=reporting_date_for_doc,
                    )
                except Exception as e:
                    errors.append(f"Document metadata save failed: {e}")

        # --- 3. Get offer summary ---
        summary = {}
        try:
            summary = self.get_offer_summary(project_id)
        except TroiError as e:
            errors.append(f"Offer summary failed: {e}")

        # The original document (before sync) has full approval rules.
        # The synced document may only have the project leader as approver.
        approval_doc_id = original_doc_id or synced_doc_id

        result = {
            "mcp_version": __version__,
            "project_id": project_id,
            "project_number": project.get("Number", ""),
            "name": spec["name"],
            "phases": phases_result,
            "total_net": summary.get("total_net", 0),
            "currency": summary.get("currency", "EUR"),
            "troi_project_url": f"{self.base_url}/site/index.php?page=project_calculation&status=55&project={project_id}",
        }
        if approval_doc_id:
            result["approval_document_id"] = approval_doc_id
        if errors:
            result["errors"] = errors

        # --- 4. Auto-download PDF ---
        if spec.get("auto_download_pdf", True):
            try:
                # Use synced document if available, otherwise find latest
                doc_id = synced_doc_id
                if not doc_id:
                    docs = self.list_documents(project_id=project_id)
                    offer_docs = [d for d in docs if d.get("DocumentType") == "O"]
                    doc_id = offer_docs[-1].get("id") if offer_docs else None
                if doc_id:
                    pdf_result = self.download_offer_pdf(document_id=doc_id, _is_draft=True)
                    result["pdf_path"] = pdf_result.get("path")
                    result["pdf_size"] = pdf_result.get("size")
                    result["document_id"] = doc_id
                    if pdf_result.get("warning"):
                        result["pdf_warning"] = pdf_result["warning"]
                else:
                    result.setdefault("errors", []).append(
                        "Auto PDF: No offer document found for this project."
                    )
            except Exception as e:
                result.setdefault("errors", []).append(
                    f"Auto PDF download failed: {e}"
                )

        return result

    def delete_calculation_position(self, position_id: int) -> dict:
        """Delete a calculation position via the legacy web UI form POST.

        The Troi REST API DELETE returns 500 for positions.  The web UI
        uses ``page=project_calculation&action=delete_calculations`` with
        a ``delete_calculation_position[{id}]=on`` flag, which sets
        ``IsDeleted=True`` on the position.

        Requires knowing the project ID and all sibling position IDs.
        """
        # First, fetch position to get its project and subproject
        pos = self.get_calculation_position(position_id)
        proj_ref = pos.get("Project", {})
        proj_id = proj_ref.get("id") if isinstance(proj_ref, dict) else None
        if not proj_id and isinstance(proj_ref, dict) and "Path" in proj_ref:
            proj_id = int(proj_ref["Path"].rsplit("/", 1)[-1])
        sub_ref = pos.get("Subproject", {})
        sub_id = sub_ref.get("id") if isinstance(sub_ref, dict) else None

        if not proj_id:
            raise TroiError(400, "Cannot determine project for position", "", "")

        # Get project status
        try:
            proj = self.get_project(proj_id)
            status_id = proj.get("ProjectStatus", {}).get("id", 55)
        except TroiError:
            status_id = 55

        # Get all visible positions in the project (form requires them)
        all_positions = self.list_calculation_positions(project_id=proj_id)
        all_ids = [str(p["id"]) for p in all_positions]
        # Also include the target position if it's not in the list
        if str(position_id) not in all_ids:
            all_ids.append(str(position_id))

        # Build form data matching the web UI format
        form_data: list[tuple[str, str]] = [
            ("forced_update_currency_id", "1"),
            ("forced_update_currency_client_id", str(self.client_id)),
            ("mode", ""),
            ("page", "project_calculation"),
            ("project", str(proj_id)),
            ("save_and_return", "0"),
            ("action", "delete_calculations"),
            ("status", str(status_id)),
        ]

        # Per-position top section
        for pid in all_ids:
            if pid == str(position_id):
                form_data.append((f"delete_calculation_position[{pid}]", "on"))
            form_data.extend([
                (f"calculation_ids[{pid}]", pid),
                (f"calculation_purchase_price_was_modified[{pid}]", "0,00"),
                (f"calculation_sale_price_was_modified[{pid}]", "0,00"),
                (f"calculation_purchase_price_was_modified[{pid}]", "0"),
                (f"calculation_sale_price_was_modified[{pid}]", "0"),
                (f"calculation_to_was_modified[{pid}]", "0"),
                (f"calculation_ksk_charge[{pid}]", "0,00"),
                (f"calculation_ksk_charge_hp[{pid}]", "0"),
                (f"calculation_is_ksk[{pid}]", "0"),
                (f"calculation_quantity[{pid}]", "0,00"),
                (f"calculation_quantity2[{pid}]", "1,00"),
                (f"calculation_unit2[{pid}]", ""),
                (f"calculation_margin[{pid}]", "0,00"),
                (f"calculation_margin_hp[{pid}]", "0.00000000"),
                (f"calculation_margin_percentage[{pid}]", "0,00"),
                (f"calculation_fixed_cost[{pid}]", "0,00"),
                (f"calculation_fixed_cost_hp[{pid}]", "0.00000000"),
                (f"calculation_fixed_cost_percentage[{pid}]", "0,00"),
                (f"calculation_quantity3[{pid}]", "0,00"),
                (f"calculation_unit3[{pid}]", ""),
                (f"calculation_precalculation_quantity[{pid}]", "0,00"),
                (f"calculation_precalculation_total_calculation[{pid}]", "0,00"),
                (f"ccalculation_precalculation_total_calculation_hp[{pid}]", "0"),
                (f"calculation_media_gross[{pid}]", "0,00"),
                (f"calculation_media_gross_hp[{pid}]", "0"),
                (f"calculation_media_discount[{pid}]", "0,00"),
                (f"calculation_media_net[{pid}]", "0,00"),
                (f"calculation_media_net_hp[{pid}]", "0"),
                (f"calculation_media_agency_discount[{pid}]", "0,00"),
            ])

        # Subproject fields
        sub_ids = {str(p.get("Subproject", {}).get("id", ""))
                   for p in all_positions if isinstance(p.get("Subproject"), dict)}
        if sub_id:
            sub_ids.add(str(sub_id))
        for sid in sub_ids:
            if sid:
                form_data.append((f"subproject_is_printable[{sid}]", "1"))
        form_data.extend([
            ("subproject_total_margin", "0,00"),
            ("subproject_total_fixed_cost", "0,00"),
            ("subproject_ksk_charge", "0,00"),
            ("subproject_profit_by_quantity3", "0,00"),
            ("subproject_profit_by_quantity3_percentage", "0,00"),
        ])

        # Per-position bottom section
        for pid in all_ids:
            form_data.extend([
                (f"calculation_quantity1[{pid}]", "0,00"),
                (f"calculation_quantity1_hp[{pid}]", "0.0000000000"),
                (f"calculation_unit1[{pid}]", "33"),
                (f"calculation_purchase_price[{pid}]", "0,00"),
                (f"calculation_purchase_price_hp[{pid}]", "0"),
                (f"calculation_sale_price[{pid}]", "0,00"),
                (f"calculation_sale_price_hp[{pid}]", "0"),
                (f"calculation_total_calculation[{pid}]", "0,00"),
                (f"calculation_total_calculation_hp[{pid}]", "0.00000000"),
                (f"calculation_total_offer[{pid}]", "0,00"),
                (f"calculation_total_offer_hp[{pid}]", "0.00000000"),
                (f"calculation_profit_hp[{pid}]", "0.00000000"),
            ])
        form_data.extend([
            ("subproject_total_calculation", "0,00"),
            ("subproject_total_offer", "0,00"),
            ("subproject_profit", "0,00"),
            ("subproject_profit_percentage", "0,00"),
        ])

        r = self.session.post(
            f"{self.base_url}/site/index.php",
            data=form_data,
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "text/html,application/xhtml+xml,*/*",
            },
            timeout=15,
            allow_redirects=False,
        )

        # 302 = success (redirect after POST)
        if r.status_code in (200, 302):
            # Verify
            check = self.get_calculation_position(position_id)
            if check.get("IsDeleted"):
                return {"status": "deleted", "id": position_id}

        # If form POST didn't work, fall back to soft-delete
        name = pos.get("Name", "")
        if not name.startswith("[DELETED]"):
            name = f"[DELETED] {name}"
        return self.update_calculation_position(position_id, {
            "Name": name,
            "Quantity": 0,
            "IsPrintable": False,
            "IsBillable": False,
        })

    # ------------------------------------------------------------------
    # Units & Reference Data
    # ------------------------------------------------------------------

    def list_units(self) -> list[dict]:
        return self._get("/misc/units", {"clientId": self.client_id})

    def list_project_types(self) -> list[dict]:
        return self._get("/misc/projectTypes", {"clientId": self.client_id})

    def list_project_statuses(self) -> list[dict]:
        return self._get("/misc/projectStatuses", {"clientId": self.client_id})

    # ------------------------------------------------------------------
    # Time Entries (Billings/Hours)
    # ------------------------------------------------------------------

    def list_time_entries(
        self,
        *,
        project_id: int | None = None,
        calculation_position_id: int | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> list[dict]:
        base = self._detect_api()
        if "rest" in base:
            params: dict[str, Any] = {"clientId": self.client_id}
            if project_id:
                params["projectId"] = project_id
            if calculation_position_id:
                params["calculationPositionId"] = calculation_position_id
            if start_date:
                params["startDate"] = _normalize_date(start_date)
            if end_date:
                params["endDate"] = _normalize_date(end_date)
            return self._get("/billings/hours", params)
        else:
            params = {"clientId": self.client_id}
            if project_id:
                params["calculationObjectId"] = project_id
            if start_date:
                params["startDate"] = _normalize_date(start_date)
            if end_date:
                params["endDate"] = _normalize_date(end_date)
            return self._get("/timeEntries", params)

    def get_time_entry(self, entry_id: int) -> dict:
        base = self._detect_api()
        if "rest" in base:
            return self._get(f"/billings/hours/{entry_id}")
        else:
            return self._get(f"/timeEntries/{entry_id}")

    def create_time_entry(self, data: dict) -> dict:
        base = self._detect_api()
        if "rest" in base:
            return self._post("/billings/hours", data)
        else:
            return self._post("/timeEntries", data)

    def update_time_entry(self, entry_id: int, data: dict) -> dict:
        base = self._detect_api()
        if "rest" in base:
            return self._put(f"/billings/hours/{entry_id}", data)
        else:
            return self._put(f"/timeEntries/{entry_id}", data)

    def delete_time_entry(self, entry_id: int) -> dict:
        base = self._detect_api()
        if "rest" in base:
            return self._delete(f"/billings/hours/{entry_id}")
        else:
            return self._delete(f"/timeEntries/{entry_id}")

    # ------------------------------------------------------------------
    # Absences
    # ------------------------------------------------------------------

    def list_absences(
        self,
        *,
        start: str | None = None,
        end: str | None = None,
        employee_id: int | None = None,
    ) -> list[dict]:
        params: dict[str, Any] = {}
        if start:
            params["start"] = _normalize_date(start)
        if end:
            params["end"] = _normalize_date(end)
        if employee_id:
            params["employeeId"] = employee_id
        return self._get("/absences", params)

    def get_absence(self, absence_id: int) -> dict:
        return self._get(f"/absences/{absence_id}")

    def get_team_absences(
        self,
        *,
        start: str,
        end: str,
        include_holidays: bool = False,
    ) -> list[dict]:
        """Aggregate absences across all active employees for a date range.

        Returns a list of dicts, one per employee who has absences:
        [{"employee": "Nachname, Vorname", "employee_id": 53, "absences": [...], "total_hours": 24}, ...]
        """
        employees = self.list_employees()
        active = [
            e for e in employees
            if e.get("IsActive") and e.get("Id") != 41  # skip System Admin
        ]

        result = []
        for emp in active:
            eid = emp["Id"]
            raw = self.list_absences(start=start, end=end, employee_id=eid)
            if not raw:
                continue

            entries = []
            for ab in raw:
                atype = ab.get("absenceType", {})
                # skip system holidays unless requested
                if not include_holidays and atype.get("absenceTypeSystem"):
                    continue
                entries.append({
                    "type": ab.get("Subject", ""),
                    "type_abbr": atype.get("absenceTypeAbbrevation", ""),
                    "start": (ab.get("Start") or "")[:10],
                    "end": (ab.get("End") or "")[:10],
                    "hours": ab.get("absenceHours", 0),
                    "half_day": ab.get("halfDay", 0),
                })

            if not entries:
                continue

            total_hours = sum(e["hours"] for e in entries)
            result.append({
                "employee": emp.get("FullName", f"{emp.get('LastName')}, {emp.get('FirstName')}"),
                "employee_id": eid,
                "absences": sorted(entries, key=lambda x: x["start"]),
                "total_hours": total_hours,
            })

        result.sort(key=lambda x: x["employee"])
        return result

    # ------------------------------------------------------------------
    # Calendar Events
    # ------------------------------------------------------------------

    def list_calendar_events(
        self,
        *,
        start: str | None = None,
        end: str | None = None,
        employee_id: int | None = None,
    ) -> list[dict]:
        params: dict[str, Any] = {}
        if start:
            params["start"] = _normalize_date(start)
        if end:
            params["end"] = _normalize_date(end)
        if employee_id:
            params["owner_employee_id"] = employee_id
        return self._get("/calendarEvents", params)

    # ------------------------------------------------------------------
    # Contacts
    # ------------------------------------------------------------------

    def list_contacts(
        self,
        *,
        search: str | None = None,
        contact_type: str | None = None,
        favorites_only: bool = False,
    ) -> list[dict]:
        params: dict[str, Any] = {}
        if search:
            params["search"] = search
        if contact_type:
            params["contactType"] = contact_type
        if favorites_only:
            params["favoritesOnly"] = "true"
        return self._get("/contacts", params)

    def get_contact(self, contact_id: int) -> dict:
        return self._get(f"/contacts/{contact_id}")

    def create_contact(
        self,
        *,
        name: str,
        contact_type: str = "company",
        parent_contact_id: int | None = None,
        # Person-only
        first_name: str | None = None,
        salutation: str | None = None,
        title: str | None = None,
        position: str | None = None,
        # Contact info
        email: str | None = None,
        phone: str | None = None,
        mobile: str | None = None,
        website: str | None = None,
        # Address
        street: str | None = None,
        zip_code: str | None = None,
        city: str | None = None,
        country: str | None = None,
        country_iso: str | None = None,
        # Misc
        remark: str | None = None,
    ) -> dict:
        """Create a contact (company, department, or person) in Troi.

        Args:
            name: Company/department name (Name2) or last name for persons.
            contact_type: "company" or "person". Departments are companies
                with a parent_contact_id pointing to the parent company.
            parent_contact_id: For departments: parent company ID.
                For persons: parent company or department ID.
            first_name: First name (persons only, maps to Name1).
            salutation: "Herr" / "Frau" (persons only).
            title: Academic title like "Dr." (persons only).
            position: Job title (persons only).
            email: Email (CompanyMail for companies, OfficeMail for persons).
            phone: Phone (CompanyPhone / OfficePhone).
            mobile: Mobile phone (persons only, OfficeMobile).
            website: Company website (companies only, CompanyWeb).
            street, zip_code, city: Address fields.
            country: Country name (default "Deutschland").
            country_iso: ISO country code (default "DE").
            remark: Free-text remark.

        Returns:
            dict with contact_id, type, name, parent_contact_id.
        """
        is_person = contact_type.lower() == "person"
        api_type = "ApiContact:Person" if is_person else "ApiContact:Company"

        payload: dict[str, Any] = {
            "Type": api_type,
            "Name2": name,
            "IsInactive": False,
            "AccessGroup": 0,
        }

        if parent_contact_id is not None:
            payload["Parent"] = {"Path": f"/contacts/{parent_contact_id}", "Id": parent_contact_id}

        if is_person:
            if first_name:
                payload["Name1"] = first_name
            if salutation:
                payload["Salutation"] = salutation
            if title:
                payload["Title"] = title
            if position:
                payload["Position"] = position
            if email:
                payload["OfficeMail"] = email
            if phone:
                payload["OfficePhone"] = phone
            if mobile:
                payload["OfficeMobile"] = mobile
        else:
            if email:
                payload["CompanyMail"] = email
            if phone:
                payload["CompanyPhone"] = phone
            if website:
                payload["CompanyWeb"] = website

        # Address fields (same API keys for both types)
        if street:
            payload["CompanyAddressStreet"] = street
        if zip_code:
            payload["CompanyAddressZipCode"] = zip_code
        if city:
            payload["CompanyAddressCity"] = city
        if country:
            payload["CompanyAddressCountry"] = country
        elif country_iso:
            # Default country name from ISO
            payload["CompanyAddressCountry"] = "Deutschland" if country_iso.upper() == "DE" else ""
        if country_iso:
            payload["CompanyAddressCountryIso"] = country_iso.upper()
        elif street or zip_code or city:
            # Default to DE if address provided but no ISO
            payload["CompanyAddressCountryIso"] = "DE"
            if "CompanyAddressCountry" not in payload:
                payload["CompanyAddressCountry"] = "Deutschland"

        if remark:
            payload["Remark"] = remark

        result = self._post("/contacts", payload)

        # Extract ID from ApiSyncItem response
        contact_id = result.get("Id") or result.get("id")
        display_name = f"{first_name} {name}" if is_person and first_name else name

        return {
            "contact_id": contact_id,
            "type": contact_type,
            "name": display_name,
            "parent_contact_id": parent_contact_id,
        }

    def update_contact(self, contact_id: int, data: dict) -> dict:
        """Update an existing contact.

        Args:
            contact_id: The contact ID to update.
            data: Dict with fields to update. Supported keys:
                name, first_name, email, phone, mobile, website,
                street, zip_code, city, country, country_iso,
                salutation, title, position, remark, is_inactive.

        Returns:
            API response dict.
        """
        # First fetch the contact to know its type
        existing = self.get_contact(contact_id)
        is_person = existing.get("Type") == "ApiContact:Person"

        payload: dict[str, Any] = {}

        # Name fields
        if "name" in data:
            payload["Name2"] = data["name"]
        if "first_name" in data:
            payload["Name1"] = data["first_name"]

        # Person-specific
        if is_person:
            if "salutation" in data:
                payload["Salutation"] = data["salutation"]
            if "title" in data:
                payload["Title"] = data["title"]
            if "position" in data:
                payload["Position"] = data["position"]
            if "email" in data:
                payload["OfficeMail"] = data["email"]
            if "phone" in data:
                payload["OfficePhone"] = data["phone"]
            if "mobile" in data:
                payload["OfficeMobile"] = data["mobile"]
        else:
            if "email" in data:
                payload["CompanyMail"] = data["email"]
            if "phone" in data:
                payload["CompanyPhone"] = data["phone"]
            if "website" in data:
                payload["CompanyWeb"] = data["website"]

        # Address
        if "street" in data:
            payload["CompanyAddressStreet"] = data["street"]
        if "zip_code" in data:
            payload["CompanyAddressZipCode"] = data["zip_code"]
        if "city" in data:
            payload["CompanyAddressCity"] = data["city"]
        if "country" in data:
            payload["CompanyAddressCountry"] = data["country"]
        if "country_iso" in data:
            payload["CompanyAddressCountryIso"] = data["country_iso"].upper()

        if "remark" in data:
            payload["Remark"] = data["remark"]
        if "is_inactive" in data:
            payload["IsInactive"] = data["is_inactive"]

        if not payload:
            return {"status": "no_changes", "contact_id": contact_id}

        return self._put(f"/contacts/{contact_id}", payload)

    # ------------------------------------------------------------------
    # Documents
    # ------------------------------------------------------------------

    def list_documents(self, *, project_id: int | None = None) -> list[dict]:
        """List documents for a project or all documents.

        Documents include offers (Type=O), invoices, etc.
        Use project_id to filter to a specific project.
        """
        params: dict[str, Any] = {"clientId": self.client_id}
        if project_id:
            params["projectId"] = project_id
        return self._get("/documents", params)

    def get_document(self, document_id: int) -> dict:
        return self._get(f"/documents/{document_id}")

    def get_offer_summary(self, project_id: int) -> dict:
        """Build a structured offer summary for a project.

        Fetches the project, its document (offer), subprojects, and all
        calculation positions, then returns a single dict with:
        - Project name, number, status, customer
        - Offer number (Angebotsnummer) and approval status
        - Positions grouped by subproject (phase), with line totals
        - Grand total (sum of all IsPrintable positions)
        - Optional positions total (sum of IsOptional positions)

        Returns:
            dict with keys: project, offer_number, status, customer,
            phases (list of {name, positions, subtotal}), total_net,
            optional_total, currency, troi_url
        """
        # 1. Project
        project = self.get_project(project_id)
        proj_name = project.get("Name", "")
        proj_number = project.get("Number", "")
        proj_status = project.get("Status", {})
        status_name = proj_status.get("Name", "") if isinstance(proj_status, dict) else str(proj_status)
        customer = project.get("Customer", {})
        customer_name = customer.get("Name", "") if isinstance(customer, dict) else str(customer)

        # 2. Document (offer) — prefer the one with an offer number, then approved
        docs = self.list_documents(project_id=project_id)
        offer_docs = [d for d in docs if d.get("DocumentType") == "O"]
        if not offer_docs:
            offer_docs = docs  # fallback to all

        # Pick best: with Number > approved (Status A) > first
        offer_doc = None
        for d in offer_docs:
            if d.get("Number"):
                offer_doc = d
                break
        if not offer_doc:
            for d in offer_docs:
                if d.get("Status") == "A":
                    offer_doc = d
                    break
        if not offer_doc and offer_docs:
            offer_doc = offer_docs[-1]  # latest

        offer_number = offer_doc.get("Number") if offer_doc else None
        doc_status = offer_doc.get("Status", "") if offer_doc else ""
        doc_id = offer_doc.get("id") if offer_doc else None
        is_approved = doc_status == "A"

        # 3. Subprojects (phases)
        subprojects = self.list_subprojects(project_id=project_id)
        sub_map: dict[int, str] = {}
        sub_order: list[int] = []
        for s in subprojects:
            sid = s.get("id")
            sub_map[sid] = s.get("Name", f"Unterprojekt {sid}")
            sub_order.append(sid)

        # 4. Positions
        positions = self.list_calculation_positions(project_id=project_id)

        # Group by subproject
        phases: dict[int, list[dict]] = {}
        ungrouped: list[dict] = []
        total_net = 0.0
        optional_total = 0.0

        for p in positions:
            if p.get("IsDeleted"):
                continue

            is_printable = p.get("IsPrintable", False)
            is_optional = p.get("IsOptional", False)
            total_calc = float(p.get("TotalCalculation", 0) or 0)
            qty = p.get("Quantity", 0) or 0
            unit_price = float(p.get("SalePrice", 0) or 0)
            # Troi doesn't auto-compute TotalCalculation for API-created positions
            if total_calc == 0 and qty and unit_price:
                total_calc = float(qty) * unit_price

            line = {
                "id": p.get("id"),
                "name": p.get("Name", ""),
                "quantity": qty,
                "unit": "",
                "sale_price": unit_price,
                "total": total_calc,
                "is_printable": is_printable,
                "is_optional": is_optional,
                "external_description": p.get("ExternalDescription", ""),
            }

            # Extract unit name
            unit_ref = p.get("Unit", {})
            if isinstance(unit_ref, dict):
                line["unit"] = unit_ref.get("Name", "")

            # Find subproject
            sub_ref = p.get("Subproject", {})
            sub_id = None
            if isinstance(sub_ref, dict):
                sub_id = sub_ref.get("id")
                if not sub_id and "Path" in sub_ref:
                    try:
                        sub_id = int(sub_ref["Path"].rsplit("/", 1)[-1])
                    except (ValueError, IndexError):
                        pass

            if sub_id and sub_id in sub_map:
                phases.setdefault(sub_id, []).append(line)
            else:
                ungrouped.append(line)

            if is_printable:
                if is_optional:
                    optional_total += total_calc
                else:
                    total_net += total_calc

        # Build ordered phase list
        phase_list = []
        for sid in sub_order:
            if sid in phases:
                lines = phases[sid]
                subtotal = sum(l["total"] for l in lines if l["is_printable"] and not l["is_optional"])
                phase_list.append({
                    "name": sub_map[sid],
                    "subproject_id": sid,
                    "positions": lines,
                    "subtotal": subtotal,
                })
        if ungrouped:
            subtotal = sum(l["total"] for l in ungrouped if l["is_printable"] and not l["is_optional"])
            phase_list.append({
                "name": "(ohne Unterprojekt)",
                "subproject_id": None,
                "positions": ungrouped,
                "subtotal": subtotal,
            })

        # Troi web link
        base = self.base_url.rstrip("/")
        troi_url = f"{base}/site/index.php?page=project_calculation&status=55&project={project_id}"

        return {
            "project_id": project_id,
            "project_name": proj_name,
            "project_number": proj_number,
            "status": status_name,
            "customer": customer_name,
            "offer_number": offer_number,
            "document_id": doc_id,
            "is_approved": is_approved,
            "phases": phase_list,
            "total_net": round(total_net, 2),
            "optional_total": round(optional_total, 2),
            "currency": "EUR",
            "troi_url": troi_url,
        }

    # ------------------------------------------------------------------
    # Approval Workflow
    # ------------------------------------------------------------------

    def start_approval(
        self,
        document_id: int,
        approver_employee_id: int,
        deadline: str | None = None,
    ) -> dict:
        """Start an approval process for a document (e.g. an offer).

        This submits the document for approval to the specified employee.
        The approval appears in the approver's Troi dashboard.

        After a successful approval request, the caller should notify the
        approver via Slack using the returned context information.

        Args:
            document_id: The Troi document ID (from list_documents).
            approver_employee_id: Employee ID of the approver.
            deadline: Approval deadline in DD.MM.YYYY format (optional).

        Returns:
            dict with keys: status, document_id, approver_name,
            project_name, offer_number, offer_total, deadline,
            troi_project_url.
        """
        # ── Pre-fetch approver name + resolve effective document ──
        approver_name = f"Employee {approver_employee_id}"
        effective_doc_id = document_id
        try:
            form = self.get_approval_form(document_id)
            effective_doc_id = form.get("effective_document_id", document_id)
            for a in form.get("available_approvers", []):
                if a["employee_id"] == approver_employee_id:
                    approver_name = a["name"]
                    break
        except Exception:
            pass

        # ── Submit the approval on the effective document ──
        path = (
            f"/site/index.php?page=approvals_manage_process"
            f"&type=Document&id={effective_doc_id}"
            f"&lightbox_context=true&action=start"
        )
        form_data: dict[str, str] = {
            "employee[1]": str(approver_employee_id),
        }
        if deadline:
            form_data["approval_date_ends_level_1"] = deadline

        r = self._form_post(path, form_data)

        # The endpoint returns JSON: {"status": true} or {"status": false, "message": "..."}
        try:
            result = r.json()
            if isinstance(result, dict) and result.get("status") is False:
                raise TroiError(
                    400,
                    result.get("message", "Approval failed"),
                    path,
                    r.text[:500],
                )
        except ValueError:
            if r.status_code == 200:
                result = {"status": True}
            else:
                raise TroiError(r.status_code, "Unexpected response from approval endpoint", path, r.text[:500])

        # ── Enrich with context for Slack notification ──
        context: dict[str, Any] = {
            "status": True,
            "document_id": document_id,
            "effective_document_id": effective_doc_id,
            "deadline": deadline,
            "approver_name": approver_name,
        }

        # Look up project & offer details from the effective document
        try:
            doc = self.get_document(effective_doc_id)
            context["offer_number"] = doc.get("Number")
            context["document_status"] = doc.get("Status")

            # Find the project for this document
            project_id = None
            raw_project = doc.get("Project") or doc.get("project")
            if isinstance(raw_project, dict):
                project_id = raw_project.get("id") or raw_project.get("Id")
            elif isinstance(raw_project, (int, str)):
                project_id = int(raw_project) if raw_project else None

            if project_id:
                context["project_id"] = project_id
                context["troi_project_url"] = (
                    f"{self.base_url}/site/index.php?page=projects_overview&id={project_id}"
                )
                try:
                    project = self.get_project(project_id)
                    context["project_name"] = project.get("Name", "")
                    # Calculate offer total from printable positions
                    positions = self.list_calculation_positions(project_id=project_id)
                    total = sum(
                        p.get("TotalCalculation", 0)
                        for p in positions
                        if p.get("IsPrintable")
                    )
                    context["offer_total"] = round(total, 2)
                except Exception:
                    pass
        except Exception:
            pass

        return context

    def _get_approval_form_raw(self, document_id: int) -> dict:
        """Fetch and parse approval form HTML for a single document.

        Returns dict with ``approvers`` list and ``approval_type`` string.
        """
        self._ensure_session()
        url = (
            f"{self.base_url}/site/index.php?page=approvals_manage_process"
            f"&type=Document&id={document_id}"
            f"&editMode=true&lightbox_context=true"
        )
        r = self.session.get(url, timeout=15)
        if r.status_code != 200:
            raise TroiError(r.status_code, "Failed to load approval form", url, r.text[:500])

        import re
        approvers = []
        for match in re.finditer(
            r'<option\s+value="(\d+)"[^>]*>([^<]+)</option>', r.text
        ):
            emp_id, name = match.groups()
            if int(emp_id) > 0:
                approvers.append({"employee_id": int(emp_id), "name": name.strip()})

        type_match = re.search(
            r'Art des Freigabeprozesses.*?<div>([^<]+)</div>', r.text, re.DOTALL
        )
        approval_type = type_match.group(1).strip() if type_match else None

        return {"approvers": approvers, "approval_type": approval_type}

    def get_approval_form(self, document_id: int) -> dict:
        """Get the approval form data for a document.

        Returns available approvers and the current approval state.

        When ``_sync_document_positions()`` creates a new document, it may
        have restricted approval rules (e.g. only the project leader).
        This method falls back to sibling documents in the same project
        when the requested document has <= 1 approver.
        """
        result = self._get_approval_form_raw(document_id)
        effective_document_id = document_id

        # Sibling fallback: if <= 1 approver, try other documents in the same project
        if len(result["approvers"]) <= 1:
            try:
                doc = self.get_document(document_id)
                raw_project = doc.get("Project") or doc.get("project")
                project_id = None
                if isinstance(raw_project, dict):
                    project_id = raw_project.get("id") or raw_project.get("Id")
                elif isinstance(raw_project, (int, str)):
                    project_id = int(raw_project) if raw_project else None

                if project_id:
                    all_docs = self.list_documents(project_id=project_id)
                    offer_docs = [
                        d for d in all_docs
                        if d.get("DocumentType") == "O" and d.get("id") != document_id
                    ]
                    for sibling in offer_docs:
                        try:
                            sibling_result = self._get_approval_form_raw(sibling["id"])
                            if len(sibling_result["approvers"]) > len(result["approvers"]):
                                result = sibling_result
                                effective_document_id = sibling["id"]
                                logger.info(
                                    "[Troi MCP] Approval fallback: doc %d → %d (%d approvers)",
                                    document_id,
                                    effective_document_id,
                                    len(result["approvers"]),
                                )
                                break
                        except Exception:
                            continue
            except Exception as e:
                logger.warning("[Troi MCP] Approval sibling fallback failed: %s", e)

        return {
            "document_id": document_id,
            "effective_document_id": effective_document_id,
            "approval_type": result["approval_type"],
            "available_approvers": result["approvers"],
        }

    # ------------------------------------------------------------------
    # Document Metadata (contact, date) via form POST
    # ------------------------------------------------------------------

    def _save_document_metadata(
        self,
        project_id: int,
        document_id: int,
        *,
        contact_id: int | None = None,
        reporting_date: str | None = None,
    ) -> None:
        """Set document contact and/or date via Troi's save_document form POST.

        The REST API silently ignores Contact/Signee updates on documents.
        This method uses the same form POST as the Troi web UI to set the
        ``document_contact`` (Gesprächspartner) and ``document_date`` fields.

        Args:
            project_id: The project ID the document belongs to.
            document_id: The document ID to update.
            contact_id: Troi Contact ID to set as Gesprächspartner (optional).
            reporting_date: Document date as DD.MM.YYYY or YYYY-MM-DD (optional).
        """
        if not contact_id and not reporting_date:
            return  # nothing to set

        self._ensure_session()

        # Normalise date to DD.MM.YYYY for the form
        date_str = ""
        if reporting_date:
            if "-" in reporting_date and len(reporting_date) >= 10:
                # YYYY-MM-DD → DD.MM.YYYY
                yr, mo, dy = reporting_date[:10].split("-")
                date_str = f"{dy}.{mo}.{yr}"
            else:
                date_str = reporting_date

        form_data: dict[str, str] = {
            "page": "project_offer",
            "project": str(project_id),
            "document_project_id": str(project_id),
            "document": str(document_id),
            "selectedNode": f"5{document_id}",
            "save_document": "save",
            "document_type": "O",
            "document_date_format": "%d.%m.%Y",
            "document_description": "",
            "document_internal_description": "",
            "document_invoice_status": "1",
        }
        if contact_id:
            form_data["document_contact"] = str(contact_id)
        if date_str:
            form_data["document_date"] = date_str

        path = (
            f"/site/index.php?page=project_offer"
            f"&project={project_id}&document={document_id}"
            f"&selectedNode=5{document_id}"
        )
        self._form_post(path, form_data)
        logger.info(
            "[Troi MCP] Document metadata saved: doc=%d contact=%s date=%s",
            document_id,
            contact_id,
            date_str or "(unchanged)",
        )

    # ------------------------------------------------------------------
    # PDF Label Fix (post-processing)
    # ------------------------------------------------------------------

    # (Label constants removed in v1.3.4 — correct form POST renders all
    # labels natively; PyMuPDF label insertion was counterproductive.)

    @staticmethod
    def _fix_pdf_labels(pdf_path: str, reporting_date: str | None = None) -> bool:
        """Post-process a Troi offer PDF for minor cleanup.

        With the corrected form POST (matching generate_offer_number style),
        the FOP template now renders all labels, dates, page numbers, and
        salutations correctly.  This method only handles edge cases:

        0. Redacts literal ``_#NULL#_`` text that Troi renders for null DB values.
        0b. Fixes truncated dates (``03. 2026`` → ``10. März 2026``) as a
            safety net, in case FOP still truncates the month in some locales.

        Args:
            pdf_path: Path to the PDF file (modified in-place).
            reporting_date: Project reporting date as ``DD.MM.YYYY`` or
                ``YYYY-MM-DD`` string.  Used to correct truncated dates.

        Returns:
            True if any fixes were applied, False otherwise.
        """
        try:
            import fitz  # pymupdf
        except ImportError:
            logger.warning("[Troi MCP] pymupdf not installed — skipping PDF fix")
            return False

        # Locate font file — check common paths
        font_path = None
        candidates = [
            os.path.expanduser("~/Library/Fonts/Poppins-SemiBold.ttf"),
            "/usr/share/fonts/truetype/poppins/Poppins-SemiBold.ttf",
            "/usr/local/share/fonts/Poppins-SemiBold.ttf",
            os.path.join(os.path.dirname(__file__), "fonts", "Poppins-SemiBold.ttf"),
        ]
        for c in candidates:
            if os.path.isfile(c):
                font_path = c
                break

        try:
            doc = fitz.open(pdf_path)
        except Exception as e:
            logger.warning("[Troi MCP] Could not open PDF for fix: %s", e)
            return False

        fixed_any = False

        # 0. Redact _#NULL#_ literal text (Troi renders null values as this string)
        for pg_idx in range(doc.page_count):
            page = doc[pg_idx]
            null_rects = page.search_for("_#NULL#_")
            if null_rects:
                for rect in null_rects:
                    page.add_redact_annot(rect, fill=(1, 1, 1))
                page.apply_redactions()
                fixed_any = True

        # 0b. Fix truncated date (FOP may show "03. 2026" instead of "10. März 2026")
        _GERMAN_MONTHS = [
            "", "Januar", "Februar", "März", "April", "Mai", "Juni",
            "Juli", "August", "September", "Oktober", "November", "Dezember",
        ]
        if reporting_date and font_path:
            try:
                # Normalise to day/month/year integers
                if "-" in reporting_date and len(reporting_date) >= 10:
                    # YYYY-MM-DD format
                    yr, mo, dy = reporting_date[:10].split("-")
                    day_int, month_int, year_s = int(dy), int(mo), yr
                else:
                    # DD.MM.YYYY format
                    parts = reporting_date.split(".")
                    if len(parts) == 3:
                        day_int = int(parts[0].strip())
                        month_int = int(parts[1].strip())
                        year_s = parts[2].strip()
                    else:
                        raise ValueError(f"Unrecognised date format: {reporting_date}")

                month_name = (
                    _GERMAN_MONTHS[month_int]
                    if 1 <= month_int <= 12
                    else str(month_int)
                )
                correct_date = f"{day_int}. {month_name} {year_s}"
                month_s = f"{month_int:02d}"
                wrong_patterns = [f"{month_s}. {year_s}", f"{month_s}.{year_s}"]

                font = fitz.Font(fontfile=font_path)
                for pg_idx in range(doc.page_count):
                    page = doc[pg_idx]
                    for wrong in wrong_patterns:
                        rects = page.search_for(wrong)
                        if rects:
                            for rect in rects:
                                page.add_redact_annot(rect, fill=(1, 1, 1))
                            page.apply_redactions()
                            origin_y = rects[0].y1 - 2.5
                            tw = fitz.TextWriter(page.rect)
                            tw.append(
                                (rects[0].x0, origin_y),
                                correct_date,
                                font=font,
                                fontsize=9.0,
                            )
                            tw.write_text(page)
                            fixed_any = True
            except (ValueError, IndexError) as exc:
                logger.warning("[Troi MCP] Date fix failed: %s", exc)

        if fixed_any:
            doc.save(pdf_path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)
            logger.info("[Troi MCP] PDF fixed: %s", pdf_path)
        doc.close()
        return fixed_any

    def _add_draft_watermark(self, pdf_path: str) -> bool:
        """Add light-grey diagonal 'Entwurf' watermark to each page of a PDF.

        Mimics the server-side watermark that Troi's FOP template renders
        for draft (unapproved) documents.  The watermark was lost when
        _sync_document_positions() started creating new documents.
        """
        try:
            import fitz
        except ImportError:
            return False

        doc = fitz.open(pdf_path)
        for page in doc:
            rect = page.rect
            tw = fitz.TextWriter(rect, opacity=0.12)
            font = fitz.Font("helv")
            fontsize = 72
            text = "Entwurf"
            text_width = font.text_length(text, fontsize=fontsize)
            # Position text so it ends up centered after rotation
            x = (rect.width - text_width) / 2
            y = rect.height / 2 + fontsize / 3  # baseline offset
            tw.append((x, y), text, font=font, fontsize=fontsize)
            center = fitz.Point(rect.width / 2, rect.height / 2)
            tw.write_text(page, morph=(center, fitz.Matrix(1, 0, 0, 1, 0, 0).prerotate(-45)))

        doc.save(pdf_path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)
        doc.close()
        logger.info("[Troi MCP] Draft watermark added: %s", pdf_path)
        return True

    # ------------------------------------------------------------------
    # PDF Download (Offer / Document print)
    # ------------------------------------------------------------------

    _SANDBOX_PREFIXES = ("/mnt/", "/home/claude/", "/home/user/", "/tmp/sandbox/")

    def _resolve_output_path(
        self, output_path: str | None, filename: str
    ) -> tuple[str, str | None]:
        """Resolve and validate output path for PDF downloads.

        Returns (resolved_path, warning_or_none).
        Falls back to ~/Downloads when the requested path is unusable.
        """
        if output_path is None:
            downloads = os.path.join(os.path.expanduser("~"), "Downloads")
            if os.path.isdir(downloads):
                return os.path.join(downloads, filename), None
            import tempfile
            return os.path.join(tempfile.gettempdir(), filename), None

        for prefix in self._SANDBOX_PREFIXES:
            if output_path.startswith(prefix):
                fallback = os.path.join(
                    os.path.expanduser("~"), "Downloads", filename
                )
                return fallback, (
                    f"Path '{output_path}' is a sandbox path "
                    f"(not writable on this machine). Saved to '{fallback}' instead."
                )

        parent_dir = os.path.dirname(output_path) or "."
        try:
            os.makedirs(parent_dir, exist_ok=True)
            if not os.access(parent_dir, os.W_OK):
                raise OSError("not writable")
        except OSError:
            fallback = os.path.join(
                os.path.expanduser("~"), "Downloads", filename
            )
            return fallback, (
                f"Cannot write to '{parent_dir}'. Saved to '{fallback}' instead."
            )

        return output_path, None

    def download_offer_pdf(
        self,
        document_id: int,
        *,
        blank_copies: int = 1,
        stationery_copies: int = 0,
        form_id: int = 45,
        output_path: str | None = None,
        _is_draft: bool | None = None,
        _reporting_date: str | None = None,
    ) -> dict:
        """Download an offer/quote as PDF from Troi.

        Uses the 3-step print workflow:
        1. POST print form to trigger PDF generation
        2. GET the FOP URL with &start to get JSON with PDF path
        3. Download the generated PDF

        Args:
            document_id: The document ID (from list_documents).
            blank_copies: Copies on blank paper (default 1).
            stationery_copies: Copies on letterhead (default 0).
            form_id: Form template ID (45 = Angebot/Offer).
            output_path: Where to save the PDF. If None, saves to
                         /tmp/troi_offer_{document_id}.pdf
            _reporting_date: Project reporting date (DD.MM.YYYY) for date
                correction in the PDF.  Auto-fetched from the project if
                not supplied.

        Returns:
            dict with keys: path (local file path), size (bytes),
            filename (original filename from Troi).
        """
        self._ensure_session()

        # --- Step 1: POST print form ---
        # Use the same form POST style as generate_offer_number() (all params
        # in form body, bare /site/index.php URL) — but WITHOUT action=generate
        # to avoid assigning an offer number.  The old style (params in URL
        # query + button_print=true) triggered a simpler Troi rendering path
        # that omitted labels, dates, page numbers, etc.
        form_data = {
            "page": "print",
            "subaction": "printing",
            "type": "O",
            "id": str(document_id),
            "view": "",
            "exemplare_ohne_logo": str(blank_copies),
            "exemplare_mit_logo": str(stationery_copies),
            "id_formular": str(form_id),
            "upload": "1",
        }
        url = f"{self.base_url}/site/index.php"
        r = self.session.post(
            url,
            data=form_data,
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "text/html,application/xhtml+xml,*/*",
            },
            timeout=30,
            allow_redirects=True,
        )
        if r.status_code >= 400:
            raise TroiError(
                r.status_code,
                f"Print request failed: HTTP {r.status_code}",
                url,
                r.text[:500],
            )
        # Detect redirect to login (session invalid)
        if "login.php" in r.url or "login" in r.url.split("?")[0]:
            raise TroiError(
                401,
                "Session redirected to login — re-authenticate and retry.",
                url,
                "",
            )

        # Extract window.open() URL from HTML response
        match = re.search(r'window\.open\("([^"]+)"', r.text)

        # Retry once with a fresh session if we got the SPA shell instead
        # of the print page.
        if not match and '<base href="/app">' in r.text:
            logger.info("[Troi MCP] Got SPA shell — retrying with fresh session")
            self._session_ready = False
            self._ensure_session()
            r = self.session.post(
                url,
                data=form_data,
                headers={
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Accept": "text/html,application/xhtml+xml,*/*",
                },
                timeout=30,
                allow_redirects=True,
            )
            match = re.search(r'window\.open\("([^"]+)"', r.text)

        if not match:
            raise TroiError(
                500,
                "Could not find PDF generation URL in print response",
                path,
                r.text[:500],
            )
        fop_raw = match.group(1)
        logger.info("[Troi MCP] FOP raw path: %s", fop_raw)

        # --- Step 2: GET FOP URL + &start → JSON with PDF path ---
        # The fop_raw path is complex relative (e.g. ../site/../prog/../site/engine/fop/print_document.php?...)
        # Extract the script + query part and build a clean absolute URL
        script_match = re.search(r'(print_document\.php\?.*)', fop_raw)
        if script_match:
            fop_url = f"{self.base_url}/site/engine/fop/{script_match.group(1)}"
        elif fop_raw.startswith("/"):
            fop_url = f"{self.base_url}{fop_raw}"
        else:
            fop_url = f"{self.base_url}/site/engine/fop/{fop_raw}"

        start_url = f"{fop_url}&start" if "?" in fop_url else f"{fop_url}?start"
        r2 = self.session.get(start_url, timeout=30)
        if r2.status_code != 200:
            raise TroiError(
                r2.status_code,
                "PDF generation start request failed",
                start_url,
                r2.text[:500],
            )

        try:
            pdf_info = r2.json()
        except ValueError:
            raise TroiError(500, "Expected JSON from PDF start", start_url, r2.text[:500])

        relative_pdf_path = pdf_info.get("url", "")
        if not relative_pdf_path:
            raise TroiError(500, "No PDF URL in response", start_url, str(pdf_info))

        # Resolve relative path (../../../config/temp/...) to absolute
        # The FOP endpoint is at /site/engine/fop/, so ../../../ = /
        if relative_pdf_path.startswith("../"):
            # Count parent traversals
            parts = relative_pdf_path.split("/")
            ups = 0
            for p in parts:
                if p == "..":
                    ups += 1
                else:
                    break
            remainder = "/".join(parts[ups:])
            pdf_url = f"{self.base_url}/{remainder}"
        elif relative_pdf_path.startswith("/"):
            pdf_url = f"{self.base_url}{relative_pdf_path}"
        else:
            pdf_url = f"{self.base_url}/{relative_pdf_path}"

        logger.info("[Troi MCP] PDF URL: %s", pdf_url)

        # --- Step 3: Poll until PDF is ready, then download ---
        pdf_bytes = None
        filename = relative_pdf_path.rsplit("/", 1)[-1] if "/" in relative_pdf_path else f"offer_{document_id}.pdf"

        for attempt in range(6):
            time.sleep(1.5 if attempt == 0 else 2.0)  # Wait for generation
            r3 = self.session.get(pdf_url, timeout=30)
            if r3.status_code == 200:
                ct = r3.headers.get("Content-Type", "")
                if "pdf" in ct or "octet-stream" in ct or len(r3.content) > 1000:
                    pdf_bytes = r3.content
                    break
            logger.info("[Troi MCP] PDF poll attempt %d: %d", attempt + 1, r3.status_code)

        if not pdf_bytes:
            raise TroiError(
                504,
                f"PDF generation timed out after {attempt + 1} attempts",
                pdf_url,
            )

        # Check if this is a draft document (not approved, no offer number)
        if _is_draft is not None:
            is_draft = _is_draft
        else:
            is_draft = True  # default to draft — safer to show watermark than miss it
            try:
                doc_details = self.get_document(document_id)
                if isinstance(doc_details, list):
                    doc_details = doc_details[0] if doc_details else {}
                has_number = bool(doc_details.get("Number"))
                is_approved = doc_details.get("Status") == "A"
                is_draft = not is_approved and not has_number
            except Exception as exc:
                logger.warning("[Troi MCP] Could not check document status for watermark: %s", exc)

        # Append _Entwurf to filename for draft documents
        if is_draft and filename.endswith(".pdf") and "_Entwurf" not in filename:
            filename = filename[:-4] + "_Entwurf.pdf"

        # Save to file
        output_path, path_warning = self._resolve_output_path(output_path, filename)

        # Delete existing file first so macOS assigns a fresh "Date Added"
        if os.path.exists(output_path):
            os.remove(output_path)

        with open(output_path, "wb") as f:
            f.write(pdf_bytes)

        # Resolve reporting date for date correction in PDF
        reporting_date = _reporting_date
        if not reporting_date:
            try:
                doc_info = self.get_document(document_id)
                if isinstance(doc_info, list):
                    doc_info = doc_info[0] if doc_info else {}
                proj_ref = doc_info.get("Project", {})
                proj_id = proj_ref.get("id") if isinstance(proj_ref, dict) else None
                if proj_id:
                    proj = self.get_project(proj_id)
                    reporting_date = proj.get("ReportingDate")
            except Exception:
                pass

        # Post-process to fix missing labels (FOP template issue with API sessions)
        labels_fixed = self._fix_pdf_labels(output_path, reporting_date=reporting_date)

        # Add "Entwurf" watermark for draft documents
        watermark_added = False
        if is_draft:
            try:
                watermark_added = self._add_draft_watermark(output_path)
            except Exception:
                pass  # Non-critical — PDF is still valid without watermark

        final_size = os.path.getsize(output_path) if labels_fixed or watermark_added else len(pdf_bytes)

        result = {
            "path": output_path,
            "size": final_size,
            "filename": filename,
            "document_id": document_id,
            "labels_fixed": labels_fixed,
            "watermark_added": watermark_added,
        }
        if path_warning:
            result["warning"] = path_warning
        return result

    def generate_offer_number(
        self,
        document_id: int,
        *,
        blank_copies: int = 1,
        stationery_copies: int = 0,
        form_id: int = 45,
        output_path: str | None = None,
    ) -> dict:
        """Assign an offer number (Belegnummer) to a document and download the final PDF.

        This is an IRREVERSIBLE action — once a number is assigned, it cannot be undone.
        The document must be approved (Status "A") before a number can be generated.

        Uses the Troi "generate number + print" workflow:
        1. POST request with action=generate to assign a Belegnummer
           (browser uses GET via form submit, but our API session requires POST
            because GET to /site/index.php redirects to login)
        2. Parse the FOP window.open URL from the HTML response
        3. Download the generated PDF

        Args:
            document_id: The document ID (from list_documents).
            blank_copies: Copies on letterhead / Briefpapier (default 1).
            stationery_copies: Copies on blank paper / Blankopapier (default 0).
            form_id: Form template ID (45 = Angebot/Offer).
            output_path: Where to save the PDF. If None, saves to ~/Downloads/.

        Returns:
            dict with keys: path (local file path), size (bytes),
            filename (original filename), document_id, offer_number (newly assigned).
        """
        self._ensure_session()

        # --- Step 1: POST request to generate number + trigger PDF ---
        # The browser form (print_form_generate_number) submits as GET, but
        # our API session only works with POST for /site/index.php endpoints
        # (GET redirects to login.php with 302).
        form_data = {
            "page": "print",
            "action": "generate",
            "subaction": "printing",
            "type": "O",
            "id": str(document_id),
            "view": "",
            "exemplare_ohne_logo": str(blank_copies),
            "exemplare_mit_logo": str(stationery_copies),
            "id_formular": str(form_id),
            "upload": "1",
        }
        url = f"{self.base_url}/site/index.php"
        r = self.session.post(
            url,
            data=form_data,
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "text/html,application/xhtml+xml,*/*",
            },
            timeout=30,
            allow_redirects=True,
        )
        if r.status_code >= 400:
            raise TroiError(
                r.status_code,
                f"Generate offer number failed: HTTP {r.status_code}",
                url,
                r.text[:500],
            )
        # Detect redirect to login (session invalid)
        if "login.php" in r.url or "login" in r.url.split("?")[0]:
            raise TroiError(
                401,
                "Session redirected to login — re-authenticate and retry.",
                url,
                "",
            )

        # Extract window.open() URL from HTML response
        match = re.search(r'window\.open\("([^"]+)"', r.text)

        # Retry with fresh session if SPA shell returned (session state issue)
        if not match and '<base href="/app">' in r.text:
            logger.info("[Troi MCP] Got SPA shell — retrying with fresh session")
            self._session_ready = False
            self._ensure_session()
            r = self.session.post(
                url,
                data=form_data,
                headers={
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Accept": "text/html,application/xhtml+xml,*/*",
                },
                timeout=30,
                allow_redirects=True,
            )
            match = re.search(r'window\.open\("([^"]+)"', r.text)

        if not match:
            # Check for error messages in the response
            error_match = re.search(r'alert\("([^"]+)"\)', r.text)
            if error_match:
                raise TroiError(400, f"Troi error: {error_match.group(1)}", url, "")
            raise TroiError(
                500,
                "Could not find PDF generation URL in response. "
                "Document may not be approved or number may already be assigned.",
                url,
                r.text[:500],
            )
        fop_raw = match.group(1)
        logger.info("[Troi MCP] Generate number – FOP raw path: %s", fop_raw)

        # --- Step 2: GET FOP URL + &start → JSON with PDF path ---
        script_match = re.search(r'(print_document\.php\?.*)', fop_raw)
        if script_match:
            fop_url = f"{self.base_url}/site/engine/fop/{script_match.group(1)}"
        elif fop_raw.startswith("/"):
            fop_url = f"{self.base_url}{fop_raw}"
        else:
            fop_url = f"{self.base_url}/site/engine/fop/{fop_raw}"

        start_url = f"{fop_url}&start" if "?" in fop_url else f"{fop_url}?start"
        r2 = self.session.get(start_url, timeout=30)
        if r2.status_code != 200:
            raise TroiError(
                r2.status_code,
                "PDF generation start request failed",
                start_url,
                r2.text[:500],
            )

        try:
            pdf_info = r2.json()
        except ValueError:
            raise TroiError(500, "Expected JSON from PDF start", start_url, r2.text[:500])

        relative_pdf_path = pdf_info.get("url", "")
        if not relative_pdf_path:
            raise TroiError(500, "No PDF URL in response", start_url, str(pdf_info))

        # Resolve relative path to absolute
        if relative_pdf_path.startswith("../"):
            parts = relative_pdf_path.split("/")
            ups = sum(1 for p in parts if p == "..")
            remainder = "/".join(parts[ups:])
            pdf_url = f"{self.base_url}/{remainder}"
        elif relative_pdf_path.startswith("/"):
            pdf_url = f"{self.base_url}{relative_pdf_path}"
        else:
            pdf_url = f"{self.base_url}/{relative_pdf_path}"

        logger.info("[Troi MCP] PDF URL: %s", pdf_url)

        # --- Step 3: Poll until PDF is ready, then download ---
        pdf_bytes = None
        filename = (
            relative_pdf_path.rsplit("/", 1)[-1]
            if "/" in relative_pdf_path
            else f"offer_{document_id}.pdf"
        )

        for attempt in range(6):
            time.sleep(1.5 if attempt == 0 else 2.0)
            r3 = self.session.get(pdf_url, timeout=30)
            if r3.status_code == 200:
                ct = r3.headers.get("Content-Type", "")
                if "pdf" in ct or "octet-stream" in ct or len(r3.content) > 1000:
                    pdf_bytes = r3.content
                    break
            logger.info("[Troi MCP] PDF poll attempt %d: %d", attempt + 1, r3.status_code)

        if not pdf_bytes:
            raise TroiError(
                504,
                f"PDF generation timed out after {attempt + 1} attempts",
                pdf_url,
            )

        # Save to file
        output_path, path_warning = self._resolve_output_path(output_path, filename)

        # Delete existing file first so macOS assigns a fresh "Date Added"
        if os.path.exists(output_path):
            os.remove(output_path)

        with open(output_path, "wb") as f:
            f.write(pdf_bytes)

        # Post-process to fix missing labels (FOP template issue with API sessions)
        labels_fixed = self._fix_pdf_labels(output_path)

        final_size = os.path.getsize(output_path) if labels_fixed else len(pdf_bytes)

        # Fetch the updated document to get the newly assigned number
        offer_number = None
        try:
            doc = self.get_document(document_id)
            offer_number = doc.get("Number")
        except Exception:
            pass

        result = {
            "path": output_path,
            "size": final_size,
            "filename": filename,
            "document_id": document_id,
            "offer_number": offer_number,
            "labels_fixed": labels_fixed,
        }
        if path_warning:
            result["warning"] = path_warning
        return result

    # ------------------------------------------------------------------
    # Booking Years
    # ------------------------------------------------------------------

    def list_booking_years(self) -> list[dict]:
        return self._get("/bookingYears", {"clientId": self.client_id})
