"""
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 + project metadata 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"]
        # Contact (Gesprächspartner) on project level
        if spec.get("contact_id"):
            post_update["Contact"] = {"Id": spec["contact_id"]}
        # Team — default "AirLST All" (ID 7)
        team_id = spec.get("team_id", 7)
        post_update["Team"] = {"Id": team_id}
        # Invoice recipient — default to contact_id
        invoice_recipient = spec.get("invoice_recipient_id") or spec.get("contact_id")
        if invoice_recipient:
            post_update["InvoiceRecipientId"] = invoice_recipient
            post_update["InvoiceRecipientPersonId"] = invoice_recipient
        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 + description via form POST (REST API silently ignores these on documents)
            if spec.get("contact_id") or spec.get("reporting_date") or spec.get("external_description"):
                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,
                        external_description=spec.get("external_description"),
                    )
                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

        # Lizenzen validation warning
        phase_names = [p["name"].lower() for p in phases_result]
        if not any("lizenz" in n for n in phase_names):
            result.setdefault("warnings", []).append(
                "Keine 'Lizenzen'-Phase im Angebot. Bei AirLST ist eine Lizenzposition Pflicht."
            )

        # --- 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,
        external_description: str | None = None,
    ) -> None:
        """Set document contact, date, and/or description via 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), ``document_date``, and
        ``document_description`` (Kurzbeschreibung) 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).
            external_description: Kurzbeschreibung shown on the PDF (optional).
        """
        if not contact_id and not reporting_date and not external_description:
            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": external_description or "",
            "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 desc=%s",
            document_id,
            contact_id,
            date_str or "(unchanged)",
            bool(external_description),
        )

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

    # Label sets for Troi offer template — FOP omits i18n strings in API sessions
    _PAGE1_LABELS = ["Angebotsnummer", "Projektnummer", "Kunde"]
    _LAST_PAGE_LABELS = ["Angebotsnummer", "Ansprechpartner ", "Projekt", "Projekt-Laufzeit"]
    _COL_HEADERS: list[tuple[str, float]] = [
        ("BEZEICHNUNG", 70.9),
        ("ANZAHL", 372.9),
        ("EINZELPREIS", 428.5),
        ("PREIS", 528.7),
    ]

    @staticmethod
    def _fix_pdf_labels(pdf_path: str, reporting_date: str | None = None) -> bool:
        """Post-process a Troi offer PDF to restore missing i18n labels.

        Troi's FOP template omits all i18n-resolved text (labels, month names,
        page number text, column headers, etc.) when PDFs are generated via
        API session instead of browser session.  This method detects the
        missing text patterns and inserts them using PyMuPDF.

        Fixes applied:
        0. Redacts literal ``_#NULL#_`` text (null DB values).
        1. Restores date month name (``10. 2026`` → ``10. März 2026``).
        2. Restores colon-only labels on page 1 (Angebotsnummer, Projektnummer, Kunde).
        3. Restores labels on last page (Angebotsnummer, Ansprechpartner, Projekt, Projekt-Laufzeit).
        4. Restores column headers (BEZEICHNUNG, ANZAHL, EINZELPREIS, PREIS).
        5. Restores title prefix (``– Title`` → ``Angebot – Title``).
        6. Restores page numbers (``1 2`` → ``Seite 1 von 2``).
        7. Restores sums labels (Nettosumme, MwSt.).
        8. Restores Zwischensumme prefix on subtotal lines.
        9. Restores last page text (Auftragsbestätigung, PROJEKT, BRUTTOAUFTRAGSSUMME,
           Ort/Datum, Vorname/Nachname signature line).

        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.

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

        # Locate font files
        font_path = None
        bold_path = None
        for c in [
            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"),
        ]:
            if os.path.isfile(c):
                font_path = c
                break
        for bp in [
            os.path.expanduser("~/Library/Fonts/Poppins-Bold.ttf"),
            "/usr/share/fonts/truetype/poppins/Poppins-Bold.ttf",
            "/usr/local/share/fonts/Poppins-Bold.ttf",
            os.path.join(os.path.dirname(__file__), "fonts", "Poppins-Bold.ttf"),
        ]:
            if os.path.isfile(bp):
                bold_path = bp
                break

        if font_path is None:
            logger.warning("[Troi MCP] Poppins-SemiBold.ttf not found — skipping PDF label fix")
            return False

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

        font = fitz.Font(fontfile=font_path)
        bold_font = fitz.Font(fontfile=bold_path) if bold_path else font
        fixed_any = False
        total_pages = doc.page_count

        # --- German month names for date fix ---
        _GERMAN_MONTHS = [
            "", "Januar", "Februar", "März", "April", "Mai", "Juni",
            "Juli", "August", "September", "Oktober", "November", "Dezember",
        ]

        # Parse reporting_date to day/month/year
        day_int = month_int = 0
        year_s = ""
        if reporting_date:
            try:
                if "-" in reporting_date and len(reporting_date) >= 10:
                    yr, mo, dy = reporting_date[:10].split("-")
                    day_int, month_int, year_s = int(dy), int(mo), yr
                else:
                    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()
            except (ValueError, IndexError):
                pass

        for pg_idx in range(total_pages):
            page = doc[pg_idx]
            is_first = pg_idx == 0
            is_last = pg_idx == total_pages - 1

            # --- 0. Redact _#NULL#_ ---
            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

            blocks = page.get_text("rawdict")["blocks"]

            # Collect SemiBold spans in the content area (y < 750, above footer)
            semibold_spans: list[dict] = []
            bold_large_spans: list[dict] = []
            for block in blocks:
                if "lines" not in block:
                    continue
                for line in block["lines"]:
                    for span in line["spans"]:
                        f = span.get("font", "")
                        chars = span.get("chars", [])
                        text = "".join(c["c"] for c in chars) if chars else ""
                        span["_text"] = text
                        if "SemiBold" in f and span["bbox"][1] < 750:
                            semibold_spans.append(span)
                        if "Bold" in f and span.get("size", 0) >= 12 and span["bbox"][1] < 750:
                            bold_large_spans.append(span)

            # --- 1. Fix truncated date on every page ---
            # Raw API PDF renders "DD. YYYY" (day. year) with the month name missing.
            if day_int and month_int and year_s:
                month_name = _GERMAN_MONTHS[month_int] if 1 <= month_int <= 12 else str(month_int)
                correct_date = f"{day_int}. {month_name} {year_s}"
                # The raw text has "DD. YYYY" where month name is missing
                for wrong in [f"{day_int}. {year_s}", f"{day_int}.{year_s}"]:
                    rects = page.search_for(wrong)
                    if rects:
                        for rect in rects:
                            page.add_redact_annot(rect, fill=(1, 1, 1))
                        page.apply_redactions()
                        tw = fitz.TextWriter(page.rect)
                        tw.append(
                            (rects[0].x0, rects[0].y1 - 2.5),
                            correct_date, font=font, fontsize=9.0,
                        )
                        tw.write_text(page)
                        fixed_any = True
                        # Re-read blocks after redaction
                        blocks = page.get_text("rawdict")["blocks"]

            # --- 2. Fix colon-only labels (page 1 info block & last page) ---
            colon_only_spans = [
                s for s in semibold_spans
                if s["_text"].strip() == ":" and s.get("size", 0) >= 8.5
            ]

            if colon_only_spans:
                if is_first:
                    labels = TroiClient._PAGE1_LABELS
                elif is_last:
                    # Last page has "AirLST:" which is NOT colon-only.
                    # The label pattern is: ":", "AirLST:", ":", ":"
                    # We need to match by position, inserting labels before
                    # the colon-only spans, and "Ansprechpartner " before "AirLST:".
                    labels = []  # handled separately below
                else:
                    labels = []

                for i, span in enumerate(colon_only_spans):
                    if i < len(labels):
                        origin = span.get("origin", (span["bbox"][0], span["bbox"][3] - 2.5))
                        tw = fitz.TextWriter(page.rect)
                        tw.append(origin, labels[i], font=font, fontsize=span["size"])
                        tw.write_text(page)
                        fixed_any = True

            # --- 3. Last page label fix (handles mixed colon-only + "AirLST:") ---
            if is_last:
                # Find ALL label-area spans (SemiBold, 9pt, y > 300 and y < 450)
                label_spans = [
                    s for s in semibold_spans
                    if s.get("size", 0) >= 8.5
                    and 300 < s["bbox"][1] < 450
                    and (s["_text"].strip() == ":" or s["_text"].strip().endswith(":"))
                    and len(s["_text"].strip()) <= 10  # short label area
                ]
                if len(label_spans) >= 4:
                    last_labels = TroiClient._LAST_PAGE_LABELS
                    for i, span in enumerate(label_spans[:4]):
                        text = span["_text"].strip()
                        if text == ":":
                            # Pure colon — insert the full label
                            origin = span.get("origin", (span["bbox"][0], span["bbox"][3] - 2.5))
                            tw = fitz.TextWriter(page.rect)
                            tw.append(origin, last_labels[i], font=font, fontsize=span["size"])
                            tw.write_text(page)
                            fixed_any = True
                        elif text == "AirLST:" and i < len(last_labels):
                            # "AirLST:" is present but "Ansprechpartner " prefix is missing
                            origin = span.get("origin", (span["bbox"][0], span["bbox"][3] - 2.5))
                            tw = fitz.TextWriter(page.rect)
                            tw.append(origin, "Ansprechpartner ", font=font, fontsize=span["size"])
                            tw.write_text(page)
                            fixed_any = True

                # --- 9a. Restore "Auftragsbestätigung" heading ---
                full_text = page.get_text()
                if "Auftragsbestätigung" not in full_text:
                    # Find the label block start (first colon-only span on this page)
                    if label_spans:
                        heading_y = label_spans[0]["bbox"][1] - 25
                        tw = fitz.TextWriter(page.rect)
                        tw.append((70.9, heading_y), "Auftragsbestätigung", font=bold_font, fontsize=14.0)
                        tw.write_text(page)
                        fixed_any = True

                # --- 9b. Restore PROJEKT + BRUTTOAUFTRAGSSUMME headers ---
                if "PROJEKT" not in full_text and "BRUTTOAUFTRAGSSUMME" not in full_text:
                    # Find the Bold line with project name + amount (size 10)
                    for s in semibold_spans:
                        if s.get("size", 0) >= 9.5 and s["_text"] and "EUR" not in s["_text"]:
                            header_y = s["bbox"][1] - 18
                            tw = fitz.TextWriter(page.rect)
                            tw.append((70.9, header_y), "PROJEKT", font=font, fontsize=9.0)
                            tw.append((430.0, header_y), "BRUTTOAUFTRAGSSUMME", font=font, fontsize=9.0)
                            tw.write_text(page)
                            fixed_any = True
                            break

                # --- 9c. Restore signature line ---
                # API renders "," and ", , ," instead of "Ort, Datum" and "Vorname, Nachname..."
                comma_rects = page.search_for(", , ,")
                if comma_rects:
                    for rect in comma_rects:
                        page.add_redact_annot(rect, fill=(1, 1, 1))
                    page.apply_redactions()
                    tw = fitz.TextWriter(page.rect)
                    tw.append(
                        (comma_rects[0].x0, comma_rects[0].y1 - 2.5),
                        "Vorname, Nachname, Position, rechtsgültige Unterschrift",
                        font=font, fontsize=9.0,
                    )
                    tw.write_text(page)
                    fixed_any = True

                # "Ort, Datum" — find standalone comma before the signature line
                # Look for a lone "," span at x≈70 that should be "Ort, Datum"
                for s in semibold_spans:
                    if s["_text"].strip() == "," and s["bbox"][0] < 100 and s["bbox"][1] > 550:
                        rect = fitz.Rect(s["bbox"])
                        page.add_redact_annot(rect, fill=(1, 1, 1))
                        page.apply_redactions()
                        origin = s.get("origin", (s["bbox"][0], s["bbox"][3] - 2.5))
                        tw = fitz.TextWriter(page.rect)
                        tw.append(origin, "Ort, Datum", font=font, fontsize=s["size"])
                        tw.write_text(page)
                        fixed_any = True
                        break

            # --- 4. Column headers on calculation pages ---
            if not is_last:
                full_text = page.get_text()
                if "EUR" in full_text and "BEZEICHNUNG" not in full_text:
                    # Find first content line below y=100 (skip header area)
                    first_content_y = None
                    for block in blocks:
                        if "lines" not in block:
                            continue
                        for line in block["lines"]:
                            ly = line["bbox"][1]
                            if 100 < ly < 750:
                                for span in line["spans"]:
                                    chars = span.get("chars", [])
                                    txt = "".join(c["c"] for c in chars)
                                    if txt.strip() and "SemiBold" in span.get("font", ""):
                                        if first_content_y is None or ly < first_content_y:
                                            first_content_y = ly

                    if first_content_y:
                        header_y = first_content_y - 18
                        tw = fitz.TextWriter(page.rect)
                        for hdr_text, hdr_x in TroiClient._COL_HEADERS:
                            tw.append((hdr_x, header_y), hdr_text, font=font, fontsize=9.0)
                        tw.write_text(page)
                        fixed_any = True

            # --- 5. Title prefix: "– Title" → "Angebot – Title" ---
            if is_first:
                for span in bold_large_spans:
                    text = span["_text"]
                    if text.startswith("\u2013 ") or text.startswith("- "):
                        # Redact and redraw with "Angebot " prefix
                        rect = fitz.Rect(span["bbox"])
                        page.add_redact_annot(rect, fill=(1, 1, 1))
                        page.apply_redactions()
                        origin = span.get("origin", (span["bbox"][0], span["bbox"][3] - 2.5))
                        tw = fitz.TextWriter(page.rect)
                        tw.append(origin, f"Angebot {text}", font=bold_font, fontsize=span["size"])
                        tw.write_text(page)
                        fixed_any = True
                        break

            # --- 6. Page numbers ---
            # API session produces NO page number text at all (FOP <fo:page-number/>
            # resolves to empty string).  First check for "X Y" digit pairs (in case
            # the FOP template starts rendering partial numbers), then fall back to
            # unconditional insertion if no "Seite" text exists.
            has_page_num = "Seite" in page.get_text() and "von" in page.get_text()
            if not has_page_num:
                # Look for digit pair "X Y" first
                inserted_pn = False
                for block in blocks:
                    if inserted_pn or "lines" not in block:
                        continue
                    for line in block["lines"]:
                        if line["bbox"][1] > 300:
                            continue
                        line_spans = line["spans"]
                        line_text = ""
                        for span in line_spans:
                            chars = span.get("chars", [])
                            line_text += "".join(c["c"] for c in chars)
                        stripped = line_text.strip()
                        import re as _re
                        pn_match = _re.match(r"^(\d+)\s+(\d+)$", stripped)
                        if pn_match:
                            for span in line_spans:
                                page.add_redact_annot(fitz.Rect(span["bbox"]), fill=(1, 1, 1))
                            page.apply_redactions()
                            origin = line_spans[0].get("origin", (line_spans[0]["bbox"][0], line_spans[0]["bbox"][3] - 2.5))
                            tw = fitz.TextWriter(page.rect)
                            tw.append(origin, f"Seite {pn_match.group(1)} von {pn_match.group(2)}", font=font, fontsize=line_spans[0].get("size", 9.0))
                            tw.write_text(page)
                            fixed_any = True
                            inserted_pn = True
                            break

                # Fallback: insert page numbers at the position near the sender
                # return address line (top-left, y≈140) — same area as the reference PDF.
                if not inserted_pn:
                    pn_text = f"Seite {pg_idx + 1} von {total_pages}"
                    tw = fitz.TextWriter(page.rect)
                    # Position: right-aligned near the date area (x≈490, y≈140)
                    tw.append((490.0, 140.0), pn_text, font=font, fontsize=6.0)
                    tw.write_text(page)
                    fixed_any = True

            # --- 7. Sums page: Nettosumme + MwSt. labels ---
            full_text = page.get_text()
            if "19,00 %" in full_text and "MwSt." not in full_text:
                # "19,00 %" should be "19,00 % MwSt."
                rects = page.search_for("19,00 %")
                if rects:
                    for r in rects:
                        # Check if there's no "MwSt." after the "%"
                        nearby = page.get_text("text", clip=fitz.Rect(r.x1, r.y0, r.x1 + 50, r.y1))
                        if "MwSt" not in nearby:
                            tw = fitz.TextWriter(page.rect)
                            tw.append((r.x1 + 2, r.y1 - 2.5), "MwSt.", font=font, fontsize=8.0)
                            tw.write_text(page)
                            fixed_any = True

            # "Nettosumme" label — should appear before the net amount on the sums page.
            # The sums page has EUR amounts + "19,00 %" in the lower area but no labels.
            # Detect sums page by: has EUR amounts at y>300 AND "19,00 %" AND no "Nettosumme".
            sums_eur_spans = [
                s for s in semibold_spans
                if "EUR" in s["_text"] and s["bbox"][1] > 300 and s["bbox"][1] < 400
            ]
            if sums_eur_spans and "19,00 %" in full_text and "Nettosumme" not in full_text:
                s = sums_eur_spans[0]
                tw = fitz.TextWriter(page.rect)
                tw.append((70.9, s["bbox"][3] - 2.5), "Nettosumme", font=font, fontsize=s["size"])
                tw.write_text(page)
                fixed_any = True

            # --- 8. Zwischensumme prefix on subtotal lines ---
            # Subtotal lines show just the phase name (e.g. "Programmierung 640,00 EUR")
            # but should be "Zwischensumme Programmierung 640,00 EUR"
            # Detect: SemiBold spans at size 8 that have a phase name + EUR amount
            # but don't start with "Zwischensumme"
            if not is_last:
                for s in semibold_spans:
                    if (s.get("size", 0) < 8.5
                            and s["_text"]
                            and "EUR" not in s["_text"]
                            and not s["_text"].startswith("Zwischensumme")
                            and s["bbox"][0] < 80):
                        # Check if there's an EUR amount on the same line (same y)
                        y = s["bbox"][1]
                        has_eur = any(
                            abs(other["bbox"][1] - y) < 2
                            and "EUR" in other["_text"]
                            for other in semibold_spans
                        )
                        # Check if this is a subtotal (same text as a Bold phase header)
                        is_subtotal = False
                        for blk in blocks:
                            if "lines" not in blk:
                                continue
                            for ln in blk["lines"]:
                                for sp in ln["spans"]:
                                    sp_chars = sp.get("chars", [])
                                    sp_text = "".join(c["c"] for c in sp_chars) if sp_chars else ""
                                    if (sp_text == s["_text"]
                                            and "Bold" in sp.get("font", "")
                                            and sp["bbox"][1] < s["bbox"][1]):
                                        is_subtotal = True
                                        break
                                if is_subtotal:
                                    break
                            if is_subtotal:
                                break
                        if has_eur and is_subtotal:
                            origin = s.get("origin", (s["bbox"][0], s["bbox"][3] - 2.5))
                            # Redact old text and rewrite with "Zwischensumme " prefix
                            rect = fitz.Rect(s["bbox"])
                            page.add_redact_annot(rect, fill=(1, 1, 1))
                            page.apply_redactions()
                            tw = fitz.TextWriter(page.rect)
                            tw.append(origin, f"Zwischensumme {s['_text']}", font=font, fontsize=s["size"])
                            tw.write_text(page)
                            fixed_any = True
                            # Re-read blocks after each redaction
                            blocks = page.get_text("rawdict")["blocks"]

        if fixed_any:
            doc.save(pdf_path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)
            logger.info("[Troi MCP] PDF labels 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 ---
        path = f"/site/index.php?page=print&type=O&id={document_id}"
        form_data = {
            "exemplare_mit_logo": str(stationery_copies),
            "exemplare_ohne_logo": str(blank_copies),
            "id_formular": str(form_id),
            "button_print": "true",
            "button_print_and_generate_number": "false",
            "printing_submitted": "true",
            "upload": "1",
        }
        r = self._form_post(path, form_data)

        # 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.  This happens when the PHP session state is
        # stale or was corrupted by a prior GET to the same 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._form_post(path, form_data)
            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})
