"""
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 html as html_mod
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])
        # Detect silent redirect to login page (session expired)
        if r.url and "login" in r.url.lower():
            raise TroiError(401, "Form POST redirected to login (session expired)", url, r.url)
        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:
        """Update a project with proper field-name mapping.

        Accepts user-friendly snake_case keys and translates them to the
        PascalCase / nested-object format the Troi REST API expects.
        Raw PascalCase keys are also accepted for backwards compatibility.

        Uses read-modify-write to avoid PUT-semantic field wipes: fetches the
        current project state, merges in only the requested changes, and sends
        the full object back.
        """
        # -- 1. Map user-friendly keys to REST API format ----------------
        updates: dict[str, Any] = {}

        if "name" in data:
            updates["Name"] = data["name"]
        if "project_status_id" in data or "status_id" in data:
            sid = data.get("project_status_id") or data["status_id"]
            updates["Status"] = {"Path": f"/misc/projectStatuses/{sid}"}
        if "team_id" in data:
            updates["Team"] = {"Id": data["team_id"]}
        if "project_leader_id" in data:
            lid = data["project_leader_id"]
            updates["Leader"] = {"Path": f"/employees/{lid}", "Id": lid}
        if "contact_id" in data:
            cid = data["contact_id"]
            updates["Contact"] = {"Path": f"/contacts/{cid}", "Id": cid}
        if "external_description" in data:
            updates["ExternalDescription"] = data["external_description"]
        if "internal_description" in data:
            updates["InternalDescription"] = data["internal_description"]
        if "tax_rate_id" in data:
            updates["TaxRate"] = {"Path": f"/misc/taxRates/{data['tax_rate_id']}", "Id": data["tax_rate_id"]}
        if "reporting_date" in data:
            updates["ReportingDate"] = data["reporting_date"]
        if "start_date" in data:
            updates["ProjectStartDate"] = data["start_date"]
        if "end_date" in data:
            updates["ProjectEndDate"] = data["end_date"]

        # Pass through raw PascalCase keys (backwards compat / internal use)
        for key, val in data.items():
            if key[0].isupper() and key not in updates:
                updates[key] = val

        if not updates:
            return {"status": "no_changes", "project_id": project_id}

        # -- 2. Read-modify-write to preserve untouched fields -----------
        current = self.get_project(project_id)
        # Keep existing values for fields we're not changing
        _PRESERVE = ("Status", "Team", "Leader", "Contact",
                      "ExternalDescription", "InternalDescription")
        for field in _PRESERVE:
            if field not in updates and current.get(field) is not None:
                updates[field] = current[field]

        return self._put(f"/projects/{project_id}", updates)

    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,
        tax_rate_id: int | 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).
            tax_rate_id: Tax rate ID (e.g. 1518 for 19% MwSt.).

        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
        if tax_rate_id is not None:
            payload["TaxRate"] = {"Path": f"/misc/taxRates/{tax_rate_id}", "Id": tax_rate_id}

        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, tax_rate_id.

        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 "tax_rate_id" in data:
            payload["TaxRate"] = {"Path": f"/misc/taxRates/{data['tax_rate_id']}", "Id": data["tax_rate_id"]}

        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"),
                tax_rate_id=spec.get("tax_rate_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)"}

        # save_preparation redirects to the active document:
        #   .../index.php?page=project_offer&project=X&document=Y&offer_view=draft
        # Extract the active doc ID from the redirect URL.
        active_doc_id: int | None = None
        if resp.url:
            import re as _re
            m = _re.search(r"[?&]document=(\d+)", resp.url)
            if m:
                active_doc_id = int(m.group(1))

        # Fallback: list new docs and pick the last one
        time.sleep(0.5)
        docs_after = self.list_documents(project_id=project_id)
        new_docs = [
            d for d in docs_after
            if d.get("DocumentType") == "O" and d.get("id") != original_doc_id
        ]
        new_doc_id = active_doc_id or (new_docs[-1]["id"] if new_docs else original_doc_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"))
            print_data.append((f"subproject_po[{sp_id}]", "3"))  # 3 = "alles"
        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] = []

        # --- 0. Validate customer/contact setup ---
        customer_hash = spec["customer"]
        try:
            customer = self.get_customer(customer_hash)
        except TroiError:
            raise TroiError(
                404,
                f"Customer '{customer_hash}' not found. "
                f"Use troi_create_customer_with_contact to create a customer first.",
                "",
            )

        # Customer must have a linked Company contact
        customer_contact = customer.get("Contact")
        if not customer_contact or not customer_contact.get("id"):
            raise TroiError(
                400,
                f"Customer '{customer.get('Name')}' has no linked company contact. "
                f"Use troi_create_customer_with_contact or troi_update_customer to "
                f"link a company contact first.",
                "",
            )
        customer_company_id = customer_contact["id"]

        # Validate customer's Client matches this MCP instance
        customer_client = customer.get("Client")
        if not customer_client:
            raise TroiError(
                400,
                f"Customer '{customer.get('Name')}' ({customer_hash}) has no Client assigned. "
                f"This customer record is incomplete and will cause 'Non unique client' errors. "
                f"Use a different customer hash or recreate the customer via troi_create_customer_with_contact.",
                "",
            )
        cust_client_id = customer_client.get("Id") or customer_client.get("id")
        if cust_client_id and str(cust_client_id) != str(self.client_id):
            raise TroiError(
                400,
                f"Customer '{customer.get('Name')}' belongs to client {cust_client_id}, "
                f"but this MCP instance uses client {self.client_id}.",
                "",
            )

        # Detect duplicate customers sharing the same contact (causes "Non unique client")
        try:
            all_customers = self.list_customers()
            same_contact = [
                c for c in all_customers
                if c.get("Contact")
                and (
                    (isinstance(c["Contact"], dict) and c["Contact"].get("id") == customer_company_id)
                    or c.get("Contact") == customer_company_id
                )
            ]
            if len(same_contact) > 1:
                dupes = [(c.get("id", "")[:8] + "…", c.get("Name")) for c in same_contact]
                raise TroiError(
                    409,
                    f"Contact {customer_company_id} is linked to {len(same_contact)} customers: "
                    f"{dupes}. This causes 'Non unique client' errors in Troi. "
                    f"Deactivate the duplicate customer in Troi web UI, then retry.",
                    "",
                )
        except TroiError as e:
            if e.status_code == 409:
                raise
            # list_customers failure shouldn't block offer creation

        # If contact_id (Gesprächspartner) is provided, validate it belongs to this customer
        if spec.get("contact_id"):
            try:
                contact = self.get_contact(spec["contact_id"])
                contact_parent = contact.get("Parent")
                parent_id = (
                    contact_parent.get("id")
                    if isinstance(contact_parent, dict)
                    else contact_parent
                )
                if parent_id != customer_company_id:
                    raise TroiError(
                        400,
                        f"Contact {spec['contact_id']} ({contact.get('Name1', '')} "
                        f"{contact.get('Name2', '')}) belongs to company contact "
                        f"{parent_id}, not to customer '{customer.get('Name')}' "
                        f"(company contact {customer_company_id}). "
                        f"The Gesprächspartner must be a person under the customer's company.",
                        "",
                    )
            except TroiError as e:
                if e.status_code == 400:
                    raise  # Re-raise our validation error
                raise TroiError(
                    404,
                    f"Contact {spec['contact_id']} not found.",
                    "",
                )

        # Warn if contact_id is missing — causes PDF layout issues
        warnings: list[str] = []
        if not spec.get("contact_id"):
            warnings.append(
                "No contact_id provided. The 'Ansprechpartner' field on the PDF "
                "will be empty, which may cause layout issues in the generated offer PDF. "
                "Consider providing a contact_id (use troi_list_contacts to find one)."
            )

        # --- 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"] = html_mod.unescape(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 on the active doc ---
        # IMPORTANT: Do NOT use REST PUT on documents — it causes Status=N→C.
        # The active doc ID comes from save_preparation's redirect URL.
        active_doc_id = synced_doc_id  # from _sync_document_positions

        if active_doc_id and (
            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
            self._session_ready = False
            self._ensure_session()
            try:
                self._save_document_metadata(
                    project_id,
                    active_doc_id,
                    contact_id=spec.get("contact_id"),
                    reporting_date=reporting_date_for_doc,
                    external_description=spec.get("external_description"),
                )
            except Exception as e:
                # save_document form POST may redirect to login but data IS saved.
                # Only log as error if it's NOT a login redirect.
                err_str = str(e)
                if "login" not in err_str.lower() and "401" not in err_str:
                    errors.append(f"Document metadata save failed: {e}")
                else:
                    logger.info("[Troi MCP] save_document redirected to login (expected, data saved)")

        # Resolve approval_document_id — prefer the active doc from save_preparation
        approval_doc_id = active_doc_id or original_doc_id

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

        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

        # Add any accumulated warnings
        if warnings:
            result.setdefault("warnings", []).extend(warnings)

        # 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:
                # Resolve project leader name for PDF Signee injection
                signee_name: str | None = None
                if spec.get("project_leader"):
                    try:
                        emp = self.get_employee(spec["project_leader"])
                        fn = emp.get("FirstName", "")
                        ln = emp.get("LastName", "")
                        if fn and ln:
                            signee_name = f"{fn} {ln}"
                    except Exception:
                        pass

                # 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, _signee_name=signee_name,
                    )
                    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

    # ------------------------------------------------------------------
    # Edit Offer
    # ------------------------------------------------------------------

    def _get_pricelist_name_map(self) -> dict[str, str]:
        """Build a mapping of position name → pricelist item UUID.

        Used to determine if a position comes from the pricelist
        (price locked) or is a custom/free position (price editable).
        """
        try:
            items = self.list_pricelist_items()
            return {item["name"]: item["uuid"] for item in items if item.get("name") and item.get("uuid")}
        except Exception:
            return {}

    def _rebuild_offer_spec(
        self,
        project_id: int,
        changes: list[dict],
        pricelist_map: dict[str, str],
    ) -> tuple[dict, list[str], list[str]]:
        """Rebuild a create_offer-compatible spec from an existing project, applying changes.

        Returns (spec, errors, warnings).
        """
        errors: list[str] = []
        warnings: list[str] = []

        # Read project metadata
        project = self.get_project(project_id)
        subprojects = self.list_subprojects(project_id=project_id)
        all_positions = self.list_calculation_positions(project_id=project_id)
        active_positions = [p for p in all_positions if not p.get("IsDeleted")]

        # Extract customer hash
        customer_ref = project.get("Customer", {})
        customer_hash = None
        if isinstance(customer_ref, dict):
            customer_path = customer_ref.get("Path", "")
            if customer_path:
                customer_hash = customer_path.rsplit("/", 1)[-1]
        if not customer_hash:
            raise TroiError(400, "Cannot determine customer for project", "", "")

        # Extract project leader
        leader_ref = project.get("ProjectLeader", {})
        leader_id = leader_ref.get("id") if isinstance(leader_ref, dict) else None

        # Extract contact
        contact_ref = project.get("Contact", {})
        contact_id = contact_ref.get("Id") or contact_ref.get("id") if isinstance(contact_ref, dict) else None

        # Extract team
        team_ref = project.get("Team", {})
        team_id = team_ref.get("Id") or team_ref.get("id") if isinstance(team_ref, dict) else None

        # Build change lookup
        updates = {c["position_id"]: c for c in changes if c.get("action") == "update" and c.get("position_id")}
        deletes = {c["position_id"] for c in changes if c.get("action") == "delete" and c.get("position_id")}
        adds = [c for c in changes if c.get("action") == "add"]

        # Group positions by subproject
        sub_positions: dict[int, list[dict]] = {}
        for pos in active_positions:
            sub_ref = pos.get("Subproject", {})
            sub_id = sub_ref.get("id") if isinstance(sub_ref, dict) else None
            if sub_id:
                sub_positions.setdefault(sub_id, []).append(pos)

        # Build phases
        phases: list[dict] = []
        for sub in subprojects:
            sub_id = sub["id"]
            sub_name = sub.get("Name", "Unnamed Phase")
            positions_in_phase: list[dict] = []

            for pos in sub_positions.get(sub_id, []):
                pos_id = pos["id"]

                # Skip deleted positions
                if pos_id in deletes:
                    continue

                pos_name = pos.get("Name", "")
                pricelist_uuid = pricelist_map.get(pos_name)

                if not pricelist_uuid:
                    errors.append(
                        f"Position '{pos_name}' (ID {pos_id}) has no matching pricelist item. "
                        f"Cannot include in new offer. Provide pricelist_item_uuid via 'add' action."
                    )
                    continue

                qty = pos.get("Quantity", 1) or 1
                sp = float(pos.get("SalePrice", 0) or 0)

                # Apply update if exists
                if pos_id in updates:
                    change = updates[pos_id]
                    qty = change.get("quantity", qty)
                    # Only allow sale_price change if NOT a pricelist item
                    if "sale_price" in change:
                        warnings.append(
                            f"Position '{pos_name}' is a pricelist item — sale_price "
                            f"change ignored (keeping {sp}). Only quantity can be updated."
                        )

                pos_spec: dict[str, Any] = {
                    "pricelist_item_uuid": pricelist_uuid,
                    "quantity": qty,
                }
                # For pricelist items we don't override price
                positions_in_phase.append(pos_spec)

            # Add new positions for this subproject
            for add_change in adds:
                if add_change.get("subproject_id") == sub_id:
                    pos_spec = {
                        "pricelist_item_uuid": add_change["pricelist_item_uuid"],
                        "quantity": add_change.get("quantity", 1),
                    }
                    if add_change.get("sale_price") is not None:
                        pos_spec["sale_price_override"] = add_change["sale_price"]
                    positions_in_phase.append(pos_spec)

            if positions_in_phase:
                phases.append({"name": sub_name, "positions": positions_in_phase})

        spec: dict[str, Any] = {
            "name": project.get("Name", "Unnamed"),
            "customer": customer_hash,
            "phases": phases,
            "auto_download_pdf": True,
        }
        if leader_id:
            spec["project_leader"] = leader_id
        if contact_id:
            spec["contact_id"] = contact_id
        if team_id:
            spec["team_id"] = team_id
        if project.get("ExternalDescription"):
            spec["external_description"] = project["ExternalDescription"]
        if project.get("InternalDescription"):
            spec["internal_description"] = project["InternalDescription"]
        if project.get("ReportingDate"):
            spec["reporting_date"] = project["ReportingDate"]
        if project.get("ProjectStartDate"):
            spec["start_date"] = project["ProjectStartDate"]
        if project.get("ProjectEndDate"):
            spec["end_date"] = project["ProjectEndDate"]

        return spec, errors, warnings

    def edit_offer(
        self,
        project_id: int,
        changes: list[dict],
        auto_download_pdf: bool = True,
    ) -> dict:
        """Edit positions on an existing offer/calculation.

        Automatically detects whether the offer is a draft (no Angebotsnummer)
        or numbered, and chooses the appropriate strategy:
        - Draft: edit positions in-place, re-sync document
        - Numbered: create a new offer (new project) with changes applied

        Args:
            project_id: Project ID of the offer to edit
            changes: List of change dicts with keys:
                action (str): "update", "add", or "delete"
                position_id (int): Required for update/delete
                quantity (number): For update/add
                sale_price (number): For update/add (only for non-pricelist items)
                pricelist_item_uuid (str): Required for add
                subproject_id (int): Required for add
            auto_download_pdf: Download updated PDF (default True)

        Returns:
            dict with mode, project_id, changes_applied, total_net, etc.
        """
        errors: list[str] = []
        warnings: list[str] = []

        if not changes:
            summary = self.get_offer_summary(project_id)
            return {
                "mcp_version": __version__,
                "mode": "no_changes",
                "project_id": project_id,
                "message": "No changes provided.",
                "total_net": summary.get("total_net", 0),
                "currency": summary.get("currency", "EUR"),
            }

        # Validate changes
        for i, change in enumerate(changes):
            action = change.get("action")
            if action not in ("update", "add", "delete"):
                raise TroiError(400, f"Change {i}: invalid action '{action}'. Must be update/add/delete.", "", "")
            if action in ("update", "delete") and not change.get("position_id"):
                raise TroiError(400, f"Change {i}: '{action}' requires position_id.", "", "")
            if action == "add" and not change.get("pricelist_item_uuid"):
                raise TroiError(400, f"Change {i}: 'add' requires pricelist_item_uuid.", "", "")
            if action == "add" and not change.get("subproject_id"):
                raise TroiError(400, f"Change {i}: 'add' requires subproject_id.", "", "")

        # --- Phase 0: Detect offer state ---
        docs = self.list_documents(project_id=project_id)
        offer_docs = [d for d in docs if d.get("DocumentType") == "O"]
        has_number = any(d.get("Number") for d in offer_docs)

        # Build pricelist name→uuid map (needed for both paths)
        pricelist_map = self._get_pricelist_name_map()

        if has_number:
            # --- Numbered offer: create new revision ---
            return self._edit_offer_new_revision(
                project_id, changes, pricelist_map, auto_download_pdf,
            )
        else:
            # --- Draft: edit in-place ---
            return self._edit_offer_draft(
                project_id, changes, pricelist_map, auto_download_pdf,
            )

    def _edit_offer_draft(
        self,
        project_id: int,
        changes: list[dict],
        pricelist_map: dict[str, str],
        auto_download_pdf: bool,
    ) -> dict:
        """Edit a draft offer in-place."""
        errors: list[str] = []
        warnings: list[str] = []
        changes_applied: list[dict] = []

        # Reverse pricelist map for lookups: name → uuid (to check if position is from pricelist)
        pricelist_names = set(pricelist_map.keys())

        for change in changes:
            action = change["action"]
            pos_id = change.get("position_id")

            if action == "update":
                try:
                    current = self.get_calculation_position(pos_id)
                    pos_name = current.get("Name", "")
                    is_pricelist = pos_name in pricelist_names

                    current_qty = current.get("Quantity", 0) or 0
                    current_sp = float(current.get("SalePrice", 0) or 0)

                    new_qty = change.get("quantity", current_qty)
                    new_sp = current_sp  # default: keep current

                    if "sale_price" in change:
                        if is_pricelist:
                            warnings.append(
                                f"Position '{pos_name}' (ID {pos_id}) is a pricelist item — "
                                f"sale_price change ignored (keeping {current_sp:.2f}). "
                                f"Only quantity can be updated."
                            )
                        else:
                            new_sp = change["sale_price"]

                    total = float(new_qty) * new_sp
                    update_data: dict[str, Any] = {
                        "Quantity": new_qty,
                        "SalePrice": new_sp,
                        "TotalCalculation": total,
                        "TotalOffer": total,
                    }
                    self.update_calculation_position(pos_id, update_data)
                    changes_applied.append({
                        "action": "update",
                        "position_id": pos_id,
                        "name": pos_name,
                        "quantity": new_qty,
                        "sale_price": new_sp,
                        "total": total,
                        "status": "ok",
                    })
                except TroiError as e:
                    errors.append(f"Update position {pos_id} failed: {e}")
                    changes_applied.append({"action": "update", "position_id": pos_id, "status": "error", "error": str(e)})

            elif action == "delete":
                try:
                    self.delete_calculation_position(pos_id)
                    changes_applied.append({"action": "delete", "position_id": pos_id, "status": "ok"})
                except TroiError as e:
                    errors.append(f"Delete position {pos_id} failed: {e}")
                    changes_applied.append({"action": "delete", "position_id": pos_id, "status": "error", "error": str(e)})

            elif action == "add":
                try:
                    pos = self.create_calculation_position_from_pricelist(
                        project_id=project_id,
                        subproject_id=change["subproject_id"],
                        item_uuid=change["pricelist_item_uuid"],
                        quantity=change.get("quantity", 1),
                        sale_price_override=change.get("sale_price"),
                    )
                    changes_applied.append({
                        "action": "add",
                        "position_id": pos.get("id"),
                        "name": pos.get("Name", ""),
                        "quantity": pos.get("Quantity", 0),
                        "sale_price": float(pos.get("SalePrice", 0) or 0),
                        "total": float(pos.get("TotalCalculation", 0) or 0),
                        "status": "ok",
                    })
                except TroiError as e:
                    errors.append(f"Add position failed: {e}")
                    changes_applied.append({"action": "add", "pricelist_item_uuid": change.get("pricelist_item_uuid"), "status": "error", "error": str(e)})

        # --- Re-sync document positions ---
        all_positions = self.list_calculation_positions(project_id=project_id)
        active = [p for p in all_positions if not p.get("IsDeleted")]
        subprojects = self.list_subprojects(project_id=project_id)
        sub_ids = [s["id"] for s in subprojects]
        cp_ids = [p["id"] for p in active]

        synced_doc_id: int | None = None
        if cp_ids and sub_ids:
            try:
                sync_result = self._sync_document_positions(project_id, sub_ids, cp_ids)
                if sync_result.get("sync_status") == "ok":
                    synced_doc_id = sync_result.get("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}")

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

        result: dict[str, Any] = {
            "mcp_version": __version__,
            "mode": "draft_edit",
            "project_id": project_id,
            "changes_applied": changes_applied,
            "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 synced_doc_id:
            result["document_id"] = synced_doc_id
        if errors:
            result["errors"] = errors
        if warnings:
            result["warnings"] = warnings

        # --- Auto-download PDF ---
        if auto_download_pdf:
            try:
                # Resolve signee name
                signee_name: str | None = None
                try:
                    proj = self.get_project(project_id)
                    leader_ref = proj.get("ProjectLeader", {})
                    leader_id = leader_ref.get("id") if isinstance(leader_ref, dict) else None
                    if leader_id:
                        emp = self.get_employee(leader_id)
                        fn = emp.get("FirstName", "")
                        ln = emp.get("LastName", "")
                        if fn and ln:
                            signee_name = f"{fn} {ln}"
                except Exception:
                    pass

                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, _signee_name=signee_name,
                    )
                    result["pdf_path"] = pdf_result.get("path")
                    result["pdf_size"] = pdf_result.get("size")
                    if pdf_result.get("warning"):
                        result["pdf_warning"] = pdf_result["warning"]
            except Exception as e:
                result.setdefault("errors", []).append(f"Auto PDF download failed: {e}")

        return result

    def _edit_offer_new_revision(
        self,
        project_id: int,
        changes: list[dict],
        pricelist_map: dict[str, str],
        auto_download_pdf: bool,
    ) -> dict:
        """Create a new offer revision from a numbered offer with changes applied."""
        spec, rebuild_errors, rebuild_warnings = self._rebuild_offer_spec(
            project_id, changes, pricelist_map,
        )
        spec["auto_download_pdf"] = auto_download_pdf

        # Create new offer
        new_result = self.create_offer(spec)

        result: dict[str, Any] = {
            "mcp_version": __version__,
            "mode": "new_revision",
            "project_id": new_result.get("project_id"),
            "original_project_id": project_id,
            "project_number": new_result.get("project_number", ""),
            "name": spec["name"],
            "phases": new_result.get("phases", []),
            "total_net": new_result.get("total_net", 0),
            "currency": new_result.get("currency", "EUR"),
            "troi_project_url": new_result.get("troi_project_url", ""),
        }
        if new_result.get("pdf_path"):
            result["pdf_path"] = new_result["pdf_path"]
            result["pdf_size"] = new_result.get("pdf_size")
        if new_result.get("document_id") or new_result.get("approval_document_id"):
            result["document_id"] = new_result.get("document_id") or new_result.get("approval_document_id")

        all_errors = rebuild_errors + (new_result.get("errors") or [])
        all_warnings = rebuild_warnings + (new_result.get("warnings") or [])
        if all_errors:
            result["errors"] = all_errors
        if all_warnings:
            result["warnings"] = all_warnings

        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

        # Re-warm session — the GET in get_approval_form can invalidate it
        self._session_ready = False
        self._ensure_session()

        # ── 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:
            # Non-JSON response — could be login page or HTML error
            if r.url and "login" in r.url.lower():
                raise TroiError(401, "Approval redirected to login (session expired)", path, r.url)
            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 approve_document(
        self,
        document_id: int,
        approver_employee_id: int,
        *,
        decision: str = "approve",
        decline_reason: str = "",
    ) -> dict:
        """Approve (or decline) a document that has a pending approval.

        This performs the actual approval action — the same as clicking
        "Freigeben" or "Ablehnen" in the Troi UI.

        The document must already have an active approval process
        (started via ``start_approval`` or manually in the UI).

        Args:
            document_id: The Troi document ID.
            approver_employee_id: Employee ID of the approver performing
                the action.  Must be a designated approver for this document.
            decision: ``"approve"`` (default) or ``"decline"``.
            decline_reason: Required when *decision* is ``"decline"``.

        Returns:
            dict with keys: status (bool), document_id, decision,
            document_status (new status after action).
        """
        if decision not in ("approve", "decline"):
            raise ValueError(f"decision must be 'approve' or 'decline', got {decision!r}")
        if decision == "decline" and not decline_reason:
            raise ValueError("decline_reason is required when decision is 'decline'")

        self._ensure_session()

        # Resolve effective document (approval may live on sibling doc)
        effective_doc_id = document_id
        try:
            form = self.get_approval_form(document_id)
            effective_doc_id = form.get("effective_document_id", document_id)
        except Exception:
            pass

        # Re-warm session — the GET in get_approval_form can invalidate it
        self._session_ready = False
        self._ensure_session()

        path = (
            f"/site/index.php?page=approvals_action"
            f"&type=Document&id={effective_doc_id}"
            f"&lightbox_context=true&save=true"
        )
        form_data = {
            "approver": str(approver_employee_id),
            "approval_decision": decision,
            "approval_declined_reason": decline_reason,
        }

        r = self._form_post(path, form_data)

        # Check response (login redirect already caught by _form_post)
        success = r.status_code == 200
        if not success:
            raise TroiError(
                r.status_code,
                f"Approval action failed (decision={decision})",
                path,
                r.text[:500],
            )

        # Verify the new document status (brief delay — Troi may lag)
        import time
        time.sleep(0.5)
        new_status = None
        try:
            doc = self.get_document(effective_doc_id)
            new_status = doc.get("Status")
        except Exception:
            pass

        return {
            "status": success,
            "document_id": document_id,
            "effective_document_id": effective_doc_id,
            "decision": decision,
            "document_status": new_status,
        }

    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_date": date_str,
            "document_description": html_mod.unescape(external_description) if external_description else "",
            "document_internal_description": "",
        }
        if contact_id:
            form_data["document_contact"] = str(contact_id)

        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),
    ]

    def _fix_pdf_labels(
        self,
        pdf_path: str,
        reporting_date: str | None = None,
        signee_name: 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).
        3b. Restores Signee name (Ansprechpartner AirLST value) on the last page.
        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.
            signee_name: Document signee name (e.g. ``"Nicolas Rohm"``),
                shown as *Ansprechpartner AirLST* on the last page.

        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

        # Poppins-Regular for value text (Signee name, etc.)
        regular_path = None
        for rp in [
            os.path.expanduser("~/Library/Fonts/Poppins-Regular.ttf"),
            "/usr/share/fonts/truetype/poppins/Poppins-Regular.ttf",
            "/usr/local/share/fonts/Poppins-Regular.ttf",
            os.path.join(os.path.dirname(__file__), "fonts", "Poppins-Regular.ttf"),
        ]:
            if os.path.isfile(rp):
                regular_path = rp
                break

        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
        regular_font = fitz.Font(fontfile=regular_path) if regular_path else fitz.Font("helv")
        fixed_any = False
        # Track fix categories for accurate reporting
        self._pdf_fix_details: dict[str, bool] = {
            "null_redacted": False,
            "umlauts_fixed": False,
            "labels_inserted": False,
            "dates_fixed": False,
            "headers_fixed": False,
            "page_numbers_fixed": False,
        }
        total_pages = doc.page_count

        # --- Pre-scan: detect which labels the PDF already has ---
        # Approved PDFs (from generate_offer_number) may already have labels
        # rendered by the FOP template.  Detect them to avoid duplicate
        # insertion and redaction side-effects (umlaut corruption, overlaps).
        _LABEL_KEYWORDS = [
            "Angebotsnummer", "Projektnummer", "Kunde", "Ansprechpartner",
            "BEZEICHNUNG", "Seite", "Nettosumme", "Zwischensumme",
            "Auftragsbestätigung", "PROJEKT", "BRUTTOAUFTRAGSSUMME", "Angebot",
        ]
        _pre_labels_found: set[str] = set()
        for _pi in range(total_pages):
            _pt = doc[_pi].get_text()
            for _kw in _LABEL_KEYWORDS:
                if _kw in _pt:
                    _pre_labels_found.add(_kw)
        pdf_already_has_labels = len(_pre_labels_found) >= 8
        logger.info(
            "[Troi MCP] Pre-scan: %d/%d labels found (%s). Skip label fixes: %s",
            len(_pre_labels_found), len(_LABEL_KEYWORDS),
            ", ".join(sorted(_pre_labels_found)),
            pdf_already_has_labels,
        )

        # --- 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#_ and _̈NULL̈_ variants ---
            # Use ONLY span-level detection with tight character-level bboxes.
            # Avoid page.search_for() which returns imprecise rectangles that
            # can clip into adjacent text (corrupting umlauts, breaking words).
            blocks = page.get_text("rawdict")["blocks"]

            semibold_spans: list[dict] = []
            bold_large_spans: list[dict] = []
            null_span_redact = False
            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)
                        # Detect NULL patterns
                        is_null = (
                            "#NULL#" in text
                            or "_#NULL#_" in text
                            or (
                                "NULL" in text
                                and len(text.strip()) <= 12
                                and any(c in text for c in ("#", "_"))
                            )
                        )
                        if is_null:
                            # Use character-level bbox for TIGHT redaction
                            if chars:
                                x0 = min(c["bbox"][0] for c in chars)
                                y0 = min(c["bbox"][1] for c in chars)
                                x1 = max(c["bbox"][2] for c in chars)
                                y1 = max(c["bbox"][3] for c in chars)
                                page.add_redact_annot(
                                    fitz.Rect(x0, y0, x1, y1), fill=(1, 1, 1),
                                )
                            else:
                                page.add_redact_annot(
                                    fitz.Rect(span["bbox"]), fill=(1, 1, 1),
                                )
                            null_span_redact = True
            if null_span_redact:
                page.apply_redactions()
                fixed_any = True
                self._pdf_fix_details["null_redacted"] = True
                # Re-read blocks after NULL redaction
                blocks = page.get_text("rawdict")["blocks"]
                semibold_spans.clear()
                bold_large_spans.clear()
                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)
                fixed_any = True

            # --- 0b. Fix broken umlauts (FOP NFD decomposition) ---
            # Troi's FOP print engine decomposes German umlauts into NFD form:
            # base vowel + U+0308 (combining diaeresis).  The font lacks a
            # glyph for U+0308, so it renders as "#" visually.
            # Example: "Berücksichtigung" → "Beru\u0308cksichtigung" (shows as "Beru#cksichtigung")
            # Fix: detect spans with combining marks, NFC-normalize, redact and rewrite.
            import unicodedata
            umlaut_redact = False
            for block in blocks:
                if "lines" not in block:
                    continue
                for line in block["lines"]:
                    for span in line["spans"]:
                        chars = span.get("chars", [])
                        if not chars:
                            continue
                        text = "".join(c["c"] for c in chars)
                        # Check for combining marks (U+0300–U+036F)
                        if not any("\u0300" <= ch <= "\u036f" for ch in text):
                            continue
                        new_text = unicodedata.normalize("NFC", text)
                        if new_text == text:
                            continue
                        # Redact using tight character-level bbox
                        x0 = min(c["bbox"][0] for c in chars)
                        y0 = min(c["bbox"][1] for c in chars)
                        x1 = max(c["bbox"][2] for c in chars)
                        y1 = max(c["bbox"][3] for c in chars)
                        page.add_redact_annot(
                            fitz.Rect(x0, y0, x1, y1), fill=(1, 1, 1),
                        )
                        page.apply_redactions()
                        # Rewrite with NFC-normalized text at the original position
                        origin = span.get("origin", (x0, y1 - 2.5))
                        sz = span.get("size", 9.0)
                        f_name = span.get("font", "")
                        if "Bold" in f_name and "SemiBold" not in f_name:
                            use_font = bold_font
                        elif "SemiBold" in f_name:
                            use_font = font
                        elif "Regular" in f_name or "Light" in f_name:
                            use_font = regular_font
                        else:
                            use_font = regular_font
                        tw = fitz.TextWriter(page.rect)
                        tw.append(origin, new_text, font=use_font, fontsize=sz)
                        tw.write_text(page)
                        umlaut_redact = True
                        fixed_any = True
                        self._pdf_fix_details["umlauts_fixed"] = True
            if umlaut_redact:
                # Re-read blocks after umlaut fix
                blocks = page.get_text("rawdict")["blocks"]
                semibold_spans.clear()
                bold_large_spans.clear()
                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 "NN. YYYY" with the month name missing.
            # FOP inconsistently renders either the day or the month number
            # (draft: month "03. 2026", final: day "10. 2026"), so we search
            # for both patterns.
            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}"
                # Build unique search patterns for both day and month numbers.
                # Sort LONGEST first so "03. 2026" is tried before "3. 2026"
                # (the short pattern is a substring and would leave the leading
                # "0" visible).
                search_patterns: set[str] = set()
                for n in (day_int, month_int):
                    search_patterns.add(f"{n}. {year_s}")
                    search_patterns.add(f"{n:02d}. {year_s}")
                    search_patterns.add(f"{n}.{year_s}")
                    search_patterns.add(f"{n:02d}.{year_s}")
                # The header date ("München, DD. MMMM YYYY") sits in the
                # top area of the page (Y < 200).  Only match/redact there
                # to avoid clobbering dates in body text or descriptions.
                _DATE_Y_MAX = 200
                date_fixed = False
                for wrong in sorted(search_patterns, key=len, reverse=True):
                    if date_fixed:
                        break
                    rects = [
                        r for r in page.search_for(wrong)
                        if r.y0 < _DATE_Y_MAX
                    ]
                    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
                        date_fixed = True
                        self._pdf_fix_details["dates_fixed"] = True
                        # Re-read blocks after redaction
                        blocks = page.get_text("rawdict")["blocks"]

            # --- 2. Fix colon-only labels (page 1 info block) ---
            # Skip if labels already present (approved PDF renders them)
            if not is_last and not pdf_already_has_labels:
                colon_only_spans = [
                    s for s in semibold_spans
                    if s["_text"].strip() == ":" and s.get("size", 0) >= 8.5
                ]
                if colon_only_spans and is_first:
                    page_text = page.get_text()
                    if "Angebotsnummer" not in page_text:
                        labels = TroiClient._PAGE1_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
                                self._pdf_fix_details["labels_inserted"] = True

            # --- 3. Last page label fix (Auftragsbestätigung) ---
            if is_last:
                # Collect label-area spans: colon-only OR short colon-ending text
                # in the label block (y 300-460). SORT by y to ensure correct
                # label assignment regardless of block iteration order.
                label_spans = sorted(
                    [
                        s for s in semibold_spans
                        if s.get("size", 0) >= 8.5
                        and 300 < s["bbox"][1] < 460
                        and (s["_text"].strip() == ":" or s["_text"].strip().endswith(":"))
                        and len(s["_text"].strip()) <= 10
                    ],
                    key=lambda s: s["bbox"][1],
                )
                # Expected labels by row position (top to bottom)
                _LP_LABELS = [
                    ("Angebotsnummer", None),
                    ("Ansprechpartner ", "AirLST:"),
                    ("Projekt", None),
                    ("Projekt-Laufzeit", None),
                ]
                if len(label_spans) >= 3 and not pdf_already_has_labels:
                    for i, span in enumerate(label_spans[:4]):
                        if i >= len(_LP_LABELS):
                            break
                        text = span["_text"].strip()
                        label, special = _LP_LABELS[i]
                        origin = span.get("origin", (span["bbox"][0], span["bbox"][3] - 2.5))
                        if text == ":":
                            tw = fitz.TextWriter(page.rect)
                            tw.append(origin, label, font=font, fontsize=span["size"])
                            tw.write_text(page)
                            fixed_any = True
                            self._pdf_fix_details["labels_inserted"] = True
                        elif special and text == special:
                            # Guard: check if label already exists nearby
                            # (approved PDFs may render "Ansprechpartner" already)
                            clip = fitz.Rect(
                                origin[0] - 120, span["bbox"][1] - 5,
                                origin[0] + 5, span["bbox"][3] + 5,
                            )
                            if label.strip() not in page.get_text("text", clip=clip):
                                tw = fitz.TextWriter(page.rect)
                                tw.append(origin, label, font=font, fontsize=span["size"])
                                tw.write_text(page)
                                fixed_any = True
                                self._pdf_fix_details["labels_inserted"] = True

                # --- 3b. Restore Signee name (Ansprechpartner AirLST value) ---
                # FOP doesn't render the employee name in API sessions.
                # The value position is at x≈311.8 (same as Projekt value),
                # on the same line as "AirLST:".
                if signee_name:
                    # Find the "AirLST:" span — may be standalone "AirLST:" or
                    # fully-rendered "Ansprechpartner AirLST:" label.
                    airlst_span = None
                    for s in semibold_spans:
                        txt = s["_text"].strip()
                        if "AirLST" in txt and txt.endswith(":"):
                            airlst_span = s
                            break
                    if airlst_span:
                        # Check if there's already a value after "AirLST:"
                        airlst_y = airlst_span["bbox"][1]
                        has_value = False
                        for block in blocks:
                            if "lines" not in block:
                                continue
                            for line in block["lines"]:
                                for span in line["spans"]:
                                    sy = span["bbox"][1]
                                    sx = span["bbox"][0]
                                    chars = span.get("chars", [])
                                    txt = "".join(c["c"] for c in chars) if chars else ""
                                    if (abs(sy - airlst_y) < 3
                                            and sx > 200
                                            and txt.strip()
                                            and "SemiBold" not in span.get("font", "")):
                                        has_value = True
                                        break
                                if has_value:
                                    break
                            if has_value:
                                break
                        if not has_value:
                            # Insert signee name at the value position (x=311.8)
                            val_y = airlst_span["bbox"][3] - 2.5
                            tw = fitz.TextWriter(page.rect)
                            tw.append(
                                (311.8, val_y),
                                signee_name,
                                font=regular_font,
                                fontsize=9.0,
                            )
                            tw.write_text(page)
                            fixed_any = True

                # --- 9a. Restore "Auftragsbestätigung" heading ---
                full_text = page.get_text()
                if "Auftragsbestätigung" not in full_text:
                    if label_spans:
                        heading_y = label_spans[0]["bbox"][1] - 25
                    else:
                        heading_y = 340.0  # fallback position
                    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 ---
                # The project name line is Poppins-Bold (not SemiBold), so search
                # ALL spans including bold_large_spans and raw blocks.
                if "PROJEKT" not in full_text and "BRUTTOAUFTRAGSSUMME" not in full_text:
                    proj_name_span = None
                    for block in blocks:
                        if "lines" not in block:
                            continue
                        for line in block["lines"]:
                            for span in line["spans"]:
                                f_name = span.get("font", "")
                                sz = span.get("size", 0)
                                chars = span.get("chars", [])
                                txt = "".join(c["c"] for c in chars) if chars else ""
                                if ("Bold" in f_name and sz >= 9.5 and txt.strip()
                                        and "EUR" not in txt
                                        and span["bbox"][1] > 450 and span["bbox"][1] < 600):
                                    proj_name_span = span
                                    break
                            if proj_name_span:
                                break
                        if proj_name_span:
                            break
                    if proj_name_span:
                        header_y = proj_name_span["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

                # --- 9c. Restore signature line + "Ort, Datum" ---
                # API renders comma-only patterns instead of full text.
                # The FOP places TWO elements on the same line (~y665):
                #   - Lone "," at x≈70  → should be "Ort, Datum"
                #   - ", , ," at x≈215  → should be "Vorname, Nachname, ..."
                # IMPORTANT: Use span-level detection, NOT search_for(), because
                # search_for(", , , ,") merges both comma groups into a single
                # rect (it treats whitespace flexibly), causing wrong placement.
                sig_span = None
                ort_span = None
                for block in blocks:
                    if "lines" not in block:
                        continue
                    for line in block["lines"]:
                        for span in line["spans"]:
                            chars = span.get("chars", [])
                            txt = "".join(c["c"] for c in chars) if chars else ""
                            y0 = span["bbox"][1]
                            x0 = span["bbox"][0]
                            if y0 < 630 or y0 > 730:
                                continue
                            stripped = txt.strip()
                            # ", , ," or ", , , ," — signature placeholder
                            if stripped in (", , ,", ", , , ,") and x0 > 100:
                                sig_span = span
                            # Lone "," at left side — Ort, Datum placeholder
                            elif stripped == "," and x0 < 100:
                                ort_span = span

                if sig_span:
                    rect = fitz.Rect(sig_span["bbox"])
                    page.add_redact_annot(rect, fill=(1, 1, 1))
                    page.apply_redactions()
                    origin = sig_span.get("origin", (sig_span["bbox"][0], sig_span["bbox"][3] - 2.5))
                    tw = fitz.TextWriter(page.rect)
                    tw.append(
                        origin,
                        "Vorname, Nachname, Position, rechtsgültige Unterschrift",
                        font=font, fontsize=9.0,
                    )
                    tw.write_text(page)
                    fixed_any = True

                if ort_span:
                    rect = fitz.Rect(ort_span["bbox"])
                    page.add_redact_annot(rect, fill=(1, 1, 1))
                    page.apply_redactions()
                    origin = ort_span.get("origin", (ort_span["bbox"][0], ort_span["bbox"][3] - 2.5))
                    tw = fitz.TextWriter(page.rect)
                    tw.append(origin, "Ort, Datum", font=font, fontsize=ort_span.get("size", 9.0))
                    tw.write_text(page)
                    fixed_any = True

                # --- 9d. Restore salutation (generic) ---
                # API renders just "," instead of "Sehr geehrte Damen und Herren,"
                # Look for lone comma between label area and body text (y 430-550)
                for block in blocks:
                    if "lines" not in block:
                        continue
                    for line in block["lines"]:
                        for span in line["spans"]:
                            chars = span.get("chars", [])
                            txt = "".join(c["c"] for c in chars) if chars else ""
                            if (txt.strip() == ","
                                    and span["bbox"][0] < 100
                                    and 430 < span["bbox"][1] < 560):
                                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, "Sehr geehrte Damen und Herren,", font=font, fontsize=span.get("size", 9.0))
                                tw.write_text(page)
                                fixed_any = True
                                break
                        else:
                            continue
                        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 Bold phase header (size >= 9.5) below y=130.
                    # Previous heuristic used any SemiBold span, which matched
                    # different elements in approved PDFs (which have more
                    # rendered text), causing wrong placement.
                    first_content_y = None
                    for block in blocks:
                        if "lines" not in block:
                            continue
                        for line in block["lines"]:
                            ly = line["bbox"][1]
                            if ly < 130 or ly > 750:
                                continue
                            for span in line["spans"]:
                                chars = span.get("chars", [])
                                txt = "".join(c["c"] for c in chars)
                                f_name = span.get("font", "")
                                # Bold phase name (e.g. "Phase 1") — the first
                                # content-relevant element on calculation pages
                                if (txt.strip()
                                        and "Bold" in f_name
                                        and span.get("size", 0) >= 9.5):
                                    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
                        # Sanity: headers should land between y=120 and y=280
                        if 120 < header_y < 280:
                            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 and not pdf_already_has_labels:
                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.
            # Search the ENTIRE page (header + footer) for digit pairs.
            has_page_num = "Seite" in page.get_text() and "von" in page.get_text()
            if not has_page_num:
                inserted_pn = False
                for block in blocks:
                    if inserted_pn or "lines" not in block:
                        continue
                    for line in block["lines"]:
                        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()
                        pn_match = re.match(r"^(\d+)\s+(\d+)$", stripped)
                        if pn_match:
                            cur_page = int(pn_match.group(1))
                            tot_page = int(pn_match.group(2))
                            # Sanity: must match actual page index
                            if cur_page == pg_idx + 1 and tot_page == total_pages:
                                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 {cur_page} von {tot_page}", 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 detailed fix info: bool for backward compat, plus breakdown
        return fixed_any

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

        Skips pages that already contain an 'Entwurf' watermark from the
        FOP template (to avoid duplicates).
        """
        try:
            import fitz
        except ImportError:
            return False

        doc = fitz.open(pdf_path)

        # Check if the FOP template already rendered the watermark
        first_page_text = doc[0].get_text() if doc.page_count > 0 else ""
        if "Entwurf" in first_page_text or "entwurf" in first_page_text.lower():
            doc.close()
            logger.info("[Troi MCP] FOP watermark already present, skipping")
            return False

        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)
            x = (rect.width - text_width) / 2
            y = rect.height / 2 + fontsize / 3
            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,
        _signee_name: 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,
            )

        # Fetch document details (for draft detection, signee, reporting date)
        doc_details: dict = {}
        try:
            doc_details = self.get_document(document_id)
            if isinstance(doc_details, list):
                doc_details = doc_details[0] if doc_details else {}
        except Exception as exc:
            logger.warning("[Troi MCP] Could not fetch document details: %s", exc)

        # 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
            if doc_details:
                has_number = bool(doc_details.get("Number"))
                is_approved = doc_details.get("Status") == "A"
                is_draft = not is_approved and not has_number

        # Extract Signee name for PDF label fix.
        # Prefer the document's Signee; fall back to caller-provided name
        # (e.g. from create_offer which resolves the project_leader employee).
        signee_name: str | None = _signee_name
        signee = doc_details.get("Signee")
        if isinstance(signee, dict):
            fn = signee.get("FirstName", "")
            ln = signee.get("LastName", "")
            if fn and ln:
                signee_name = f"{fn} {ln}"

        # 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
        # Also validate project metadata that affects PDF layout
        reporting_date = _reporting_date
        metadata_warnings: list[str] = []
        proj: dict = {}
        try:
            proj_ref = doc_details.get("Project", {})
            proj_id = proj_ref.get("id") if isinstance(proj_ref, dict) else None
            if proj_id:
                proj = self.get_project(proj_id)
                if not reporting_date:
                    reporting_date = proj.get("ReportingDate")

                # Check for null metadata that causes PDF layout issues
                status = proj.get("Status")
                if not status or (isinstance(status, dict) and not status.get("id")):
                    metadata_warnings.append(
                        "Project has no Status set (null). This can cause PDF layout "
                        "issues. Use troi_update_project to set a Status."
                    )
                team = proj.get("Team")
                if not team or (isinstance(team, dict) and not team.get("Id") and not team.get("id")):
                    metadata_warnings.append(
                        "Project has no Team set (null). This can cause PDF layout "
                        "issues. Use troi_update_project to set Team (e.g. 7 = AirLST All)."
                    )
                contact = proj.get("Contact")
                if not contact or (isinstance(contact, dict) and not contact.get("Id") and not contact.get("id")):
                    metadata_warnings.append(
                        "Project has no Contact/Ansprechpartner set (null). The "
                        "'Ansprechpartner' field on the PDF will be empty, which may "
                        "cause layout issues."
                    )
        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, signee_name=signee_name,
        )

        # Note: The FOP template renders its own "Entwurf" watermark for
        # draft documents.  We no longer add a custom watermark to avoid
        # duplicates.
        watermark_added = False

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

        # Get detailed fix breakdown
        fix_details = getattr(self, "_pdf_fix_details", {})

        result: dict[str, Any] = {
            "path": output_path,
            "size": final_size,
            "filename": filename,
            "document_id": document_id,
            "labels_fixed": labels_fixed,
            "watermark_added": watermark_added,
        }
        if fix_details:
            result["fix_details"] = {k: v for k, v in fix_details.items() if v}
        if path_warning:
            result["warning"] = path_warning
        if metadata_warnings:
            result["metadata_warnings"] = metadata_warnings
        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)

        # Fetch document details for reporting date + signee
        reporting_date = None
        signee_name: str | None = None
        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")
            signee = doc_info.get("Signee")
            if isinstance(signee, dict):
                fn = signee.get("FirstName", "")
                ln = signee.get("LastName", "")
                if fn and ln:
                    signee_name = f"{fn} {ln}"
        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, signee_name=signee_name,
        )

        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

        fix_details = getattr(self, "_pdf_fix_details", {})

        result = {
            "path": output_path,
            "size": final_size,
            "filename": filename,
            "document_id": document_id,
            "offer_number": offer_number,
            "labels_fixed": labels_fixed,
        }
        if fix_details:
            result["fix_details"] = {k: v for k, v in fix_details.items() if v}
        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})
