"""
ABOS AI Worker – MCP Tool-Calling Server (Phase 4 enablement)
=================================================================
Exposes a first batch of well-described tools, backed by real abos-api
endpoints, over the Model Context Protocol (MCP) — so an LLM-driven agent
(Claude Desktop, an internal ABOS copilot, etc.) can actually *take actions*
against the ERP, not just draft text. Everything else in this service
(app/email_ai.py, app/project_ai.py, app/email_analyzer.py, ...) is
generation-only: Spring Boot calls it, an LLM produces text/JSON, Spring Boot
decides what to do with it. This module is the first place an external agent
picks its own tools and calls them directly.

Transport & mounting
---------------------
`mcp_server` is mounted at /mcp in app/main.py via
`mcp_server.streamable_http_app()`. Streamable HTTP (not stdio) because this
is a shared, always-on service reached over the network, same as every other
router in this app. `stateless_http=True` because this service is documented
as a stateless AI worker (see app/main.py's FastAPI description) — no
per-connection session state is kept between tool calls.

Auth model — read before adding a tool
----------------------------------------
There is no separate "MCP service account". Every tool call is executed as
whichever ABOS user's JWT is forwarded on the MCP HTTP request's
`Authorization: Bearer <token>` header — `_auth_token()` below reads it
straight off the raw Starlette request and `call_abos_api()` forwards it
unchanged to abos-api. abos-api's own JwtAuthFilter + ModuleAccessFilter then
enforce tenant scoping and module/permission checks exactly as they would for
a normal frontend request; nothing here re-implements or bypasses that. A
missing/invalid token makes every tool fail closed with a clear message
instead of silently falling back to a shared credential (contrast with
app/email_analyzer.py's `fetch_tenant_credentials()`, which mock-fallbacks on
failure — deliberately not replicated here).

Error handling
--------------
Tools never let an AbosApiError (or anything else) propagate as an MCP
protocol-level error / stack trace. Each tool catches AbosApiError and
returns a small `{"error": true, "message": ..., "status_code": ...}` object
instead, so the calling LLM sees a clean, actionable message it can react to
(e.g. re-ask the user, retry with corrected input) — see _tool_error().

Scope
-----
Nine tools total, chosen for concrete day-one value — deliberately not
attempting exhaustive CRM/Inventory/Finance/HRM/Projects coverage:
    - search_crm_leads / create_crm_lead     (CRM)
    - get_inventory_stock_levels              (Inventory)
    - search_inventory_items                  (Inventory — name/SKU -> ID lookup)
    - get_invoice / list_invoices              (Finance)
    - list_projects / get_project_tasks       (Projects)
    - search_employees                        (HRM)
Adding another tool later is just another `@mcp_server.tool()` function that
calls `call_abos_api()` — follow the pattern below.

Two of these (search_inventory_items, search_employees) wrap abos-api list
endpoints that have no server-side name-search parameter at all (confirmed by
reading InventoryController/WarehouseService and EmployeeController/
EmployeeService — see each tool's docstring for specifics) and instead filter
client-side over what abos-api returns. This is a deliberate, documented
trade-off rather than inventing a new abos-api endpoint: it is honestly
labelled as best-effort/bounded in each tool's docstring so the calling LLM
doesn't over-trust it for a very large tenant.
"""

from __future__ import annotations

import logging
from typing import Annotated, Any, Literal, Optional

from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import Field

from app.abos_api_client import AbosApiError, call_abos_api

logger = logging.getLogger(__name__)

mcp_server = FastMCP(
    name="abos-erp-tools",
    instructions=(
        "Tools for taking real actions in the ABOS ERP: searching/creating CRM leads, "
        "checking inventory stock levels and looking up items/warehouses by name, looking "
        "up invoices, listing projects and their tasks, and searching employees. Every tool "
        "call acts as the currently authenticated ABOS user for their own tenant — there is "
        "no way to act as a different tenant or a different user, and no way to see data "
        "outside what that user's ABOS permissions already allow. If a tool returns "
        "{\"error\": true, ...}, read the \"message\" field and either fix the input or "
        "tell the user what went wrong — do not retry blindly."
    ),
    stateless_http=True,
    # FastMCP auto-enables Host/Origin DNS-rebinding checks that only allow
    # "localhost"/"127.0.0.1" (its defaults assume a standalone `mcp run` server).
    # This server is mounted inside the existing FastAPI app instead and reached
    # the same way every other router here is — internally, by abos-api or an
    # internal orchestrator, never directly by an end-user's browser — and the
    # real access control is the forwarded ABOS JWT (see _auth_token / call_abos_api),
    # not the Host header. Disabling this here avoids spurious 421s the moment this
    # service is reached through anything other than exactly "localhost".
    transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _auth_token(ctx: Context) -> str:
    """
    Read the caller's forwarded ABOS JWT off the raw HTTP request behind this MCP call.

    Returns "" (never raises) when it's missing or this transport doesn't expose a raw
    request, so the *tool* surfaces a clean AbosApiError-shaped message via
    call_abos_api()'s own fail-closed check rather than a generic MCP-level error.
    """
    try:
        request = ctx.request_context.request
    except (AttributeError, LookupError):
        return ""
    if request is None or not hasattr(request, "headers"):
        return ""
    auth_header = request.headers.get("authorization", "") or ""
    if auth_header.lower().startswith("bearer "):
        return auth_header[7:].strip()
    return ""


def _tool_error(exc: AbosApiError) -> dict[str, Any]:
    """Structured, LLM-readable error payload — see module docstring's "Error handling"."""
    logger.warning("MCP tool call failed against abos-api: %s", exc.message)
    return {"error": True, "message": exc.message, "status_code": exc.status_code}


# ============================================================================
# CRM — Leads
# ============================================================================

@mcp_server.tool()
async def search_crm_leads(
    ctx: Context,
    status: Annotated[
        Optional[str],
        Field(
            description=(
                "Filter by lead status, e.g. 'NEW', 'CONTACTED', 'QUALIFIED', 'LOST'. "
                "Case-sensitive, must match an existing status exactly. Omit to return "
                "leads in any status."
            )
        ),
    ] = None,
    business_id: Annotated[
        Optional[int],
        Field(description="Filter to leads under this specific business unit's numeric ID. Omit to search across all business units the caller can see."),
    ] = None,
    page: Annotated[
        int, Field(description="Zero-based page index for pagination.", ge=0)
    ] = 0,
    page_size: Annotated[
        int,
        Field(description="Number of leads per page (1-100).", ge=1, le=100),
    ] = 20,
) -> dict[str, Any]:
    """Search/list CRM leads for the current tenant, optionally filtered by status and
    business unit. Use this before create_crm_lead to check whether a similar lead
    already exists, or to answer questions like "how many new leads do we have"."""
    token = _auth_token(ctx)
    params: dict[str, Any] = {"page": page, "size": page_size}
    if status:
        params["status"] = status
    if business_id is not None:
        params["businessId"] = business_id
    try:
        return await call_abos_api(
            method="GET",
            path="/api/leads/event/lead-list",
            auth_token=token,
            params=params,
        )
    except AbosApiError as exc:
        return _tool_error(exc)


@mcp_server.tool()
async def create_crm_lead(
    ctx: Context,
    first_name: Annotated[Optional[str], Field(description="Lead's first name (max 100 chars).")] = None,
    last_name: Annotated[Optional[str], Field(description="Lead's last name (max 100 chars).")] = None,
    email: Annotated[Optional[str], Field(description="Lead's email address (max 255 chars).")] = None,
    phone: Annotated[Optional[str], Field(description="Lead's phone number (max 50 chars).")] = None,
    company_name: Annotated[Optional[str], Field(description="Lead's company/organization name (max 255 chars).")] = None,
    source: Annotated[
        Optional[str],
        Field(description="Where this lead came from, e.g. 'Website', 'Referral', 'Cold Call', 'Event' (max 100 chars)."),
    ] = None,
    business_id: Annotated[
        Optional[int],
        Field(description="Numeric ID of the business unit this lead belongs to, if the tenant has more than one."),
    ] = None,
    notes: Annotated[Optional[str], Field(description="Free-text notes about this lead.")] = None,
) -> dict[str, Any]:
    """Create a new CRM lead for the current tenant. At least one of first_name, last_name,
    email, phone, or company_name should be provided so the lead is identifiable — this
    is not enforced client-side, abos-api will reject an entirely empty lead. Consider
    calling search_crm_leads first to avoid creating a duplicate."""
    token = _auth_token(ctx)
    create_lead: dict[str, Any] = {}
    if first_name is not None:
        create_lead["firstName"] = first_name
    if last_name is not None:
        create_lead["lastName"] = last_name
    if email is not None:
        create_lead["email"] = email
    if phone is not None:
        create_lead["phone"] = phone
    if company_name is not None:
        create_lead["companyName"] = company_name
    if source is not None:
        create_lead["source"] = source
    if business_id is not None:
        create_lead["businessId"] = business_id
    if notes is not None:
        create_lead["notes"] = notes

    try:
        return await call_abos_api(
            method="POST",
            path="/api/leads/event",
            auth_token=token,
            json_body={"createLead": create_lead},
            event="CREATE_LEAD",
        )
    except AbosApiError as exc:
        return _tool_error(exc)


# ============================================================================
# Inventory — Stock
# ============================================================================

@mcp_server.tool()
async def get_inventory_stock_levels(
    ctx: Context,
    warehouse_id: Annotated[
        Optional[int],
        Field(description="Numeric ID of a specific warehouse to filter to. Omit to include all warehouses."),
    ] = None,
    inventory_item_id: Annotated[
        Optional[int],
        Field(description="Numeric ID of a specific inventory item (SKU) to filter to. Omit to include all items."),
    ] = None,
) -> dict[str, Any]:
    """Look up current stock levels (quantity on hand, quantity reserved, reorder point,
    and whether an item is at/below its reorder point) for the current tenant.
    Provide warehouse_id and/or inventory_item_id to narrow the results; provide neither
    to get a full stock snapshot across every warehouse and item. Note: this only reports
    quantities — resolving an item's name/SKU to its inventory_item_id, or a warehouse's
    name to its warehouse_id, requires a lookup tool this service does not yet expose."""
    token = _auth_token(ctx)
    params: dict[str, Any] = {}
    if warehouse_id is not None:
        params["warehouseId"] = warehouse_id
    if inventory_item_id is not None:
        params["inventoryItemId"] = inventory_item_id
    try:
        return await call_abos_api(
            method="GET",
            path="/api/inventory/event/stock-levels",
            auth_token=token,
            params=params,
        )
    except AbosApiError as exc:
        return _tool_error(exc)


@mcp_server.tool()
async def search_inventory_items(
    ctx: Context,
    query: Annotated[
        str,
        Field(
            description=(
                "Case-insensitive substring to search for, e.g. 'blue widget' or 'main "
                "warehouse'. Matched against inventory item name/SKU and/or warehouse name "
                "depending on search_in."
            ),
            min_length=1,
        ),
    ],
    search_in: Annotated[
        Literal["items", "warehouses", "both"],
        Field(
            description=(
                "Which catalog to search: 'items' for inventory items/SKUs only, "
                "'warehouses' only, or 'both' (default) to search both in one call."
            )
        ),
    ] = "both",
    limit: Annotated[
        int,
        Field(description="Maximum number of matches to return per category (1-50).", ge=1, le=50),
    ] = 20,
) -> dict[str, Any]:
    """Resolve an inventory item's or warehouse's name/SKU to the numeric ID that
    get_inventory_stock_levels needs. abos-api's inventory-item-list and warehouse-list
    endpoints do not accept a name/search query parameter — they only return the tenant's
    full, unfiltered list — so this tool fetches that full list and filters it client-side
    by a case-insensitive substring match. This is fine for a typeahead-sized catalog but
    may be slow, or miss nothing (it always scans everything abos-api returns), for a very
    large tenant. Always call this before get_inventory_stock_levels when you only know a
    product or warehouse by name rather than its ID."""
    token = _auth_token(ctx)
    needle = query.lower()
    result: dict[str, Any] = {"query": query, "items": [], "warehouses": []}
    try:
        if search_in in ("items", "both"):
            items = await call_abos_api(
                method="GET",
                path="/api/inventory/event/inventory-item-list",
                auth_token=token,
            )
            for entry in items or []:
                name = entry.get("name") or ""
                sku = entry.get("sku") or ""
                if needle in name.lower() or needle in sku.lower():
                    result["items"].append({"id": entry.get("id"), "name": name, "sku": sku})
                    if len(result["items"]) >= limit:
                        break
        if search_in in ("warehouses", "both"):
            warehouses = await call_abos_api(
                method="GET",
                path="/api/inventory/event/warehouse-list",
                auth_token=token,
            )
            for entry in warehouses or []:
                name = entry.get("name") or ""
                if needle in name.lower():
                    result["warehouses"].append({"id": entry.get("id"), "name": name})
                    if len(result["warehouses"]) >= limit:
                        break
    except AbosApiError as exc:
        return _tool_error(exc)
    return result


# ============================================================================
# Finance — Invoices
# ============================================================================

@mcp_server.tool()
async def get_invoice(
    ctx: Context,
    invoice_id: Annotated[int, Field(description="Numeric ID of the invoice to retrieve.")],
) -> dict[str, Any]:
    """Look up a single invoice by its numeric ID — status, amounts, line items, and
    associated client. Use list_invoices first if you only know the client or status,
    not the exact invoice ID."""
    token = _auth_token(ctx)
    try:
        return await call_abos_api(
            method="GET",
            path="/api/invoices/event/invoice",
            auth_token=token,
            params={"invoiceId": invoice_id},
        )
    except AbosApiError as exc:
        return _tool_error(exc)


@mcp_server.tool()
async def list_invoices(
    ctx: Context,
    client_id: Annotated[
        Optional[int],
        Field(description="Filter to invoices for this specific client's numeric ID. Omit to search across all clients."),
    ] = None,
    status: Annotated[
        Optional[str],
        Field(description="Filter by invoice status, e.g. 'DRAFT', 'SENT', 'PAID', 'OVERDUE', 'VOID'. Case-sensitive, must match exactly."),
    ] = None,
    page: Annotated[int, Field(description="Zero-based page index for pagination.", ge=0)] = 0,
    page_size: Annotated[
        int, Field(description="Number of invoices per page (1-100).", ge=1, le=100)
    ] = 20,
) -> dict[str, Any]:
    """List/search invoices for the current tenant, optionally filtered by client and/or
    status — e.g. to answer "which invoices are overdue for client X"."""
    token = _auth_token(ctx)
    params: dict[str, Any] = {"page": page, "size": page_size}
    if client_id is not None:
        params["clientId"] = client_id
    if status:
        params["status"] = status
    try:
        return await call_abos_api(
            method="GET",
            path="/api/invoices/event/invoice-list",
            auth_token=token,
            params=params,
        )
    except AbosApiError as exc:
        return _tool_error(exc)


# ============================================================================
# Projects — Projects & Workitems
# ============================================================================

@mcp_server.tool()
async def list_projects(
    ctx: Context,
    search: Annotated[
        Optional[str],
        Field(description="Case-insensitive substring to match against project name/description. Omit to list every project."),
    ] = None,
    status: Annotated[
        Optional[str],
        Field(description="Filter by project status, e.g. 'ACTIVE', 'ON_HOLD', 'COMPLETED', 'CANCELLED'. Case-sensitive, must match exactly."),
    ] = None,
    business_id: Annotated[
        Optional[int],
        Field(description="Filter to projects under this specific business unit's numeric ID."),
    ] = None,
    owner_user_id: Annotated[
        Optional[int],
        Field(description="Filter to projects owned by this specific user's numeric ID."),
    ] = None,
    page: Annotated[int, Field(description="Zero-based page index for pagination.", ge=0)] = 0,
    page_size: Annotated[
        int, Field(description="Number of projects per page (1-100).", ge=1, le=100)
    ] = 20,
) -> dict[str, Any]:
    """List/search projects for the current tenant — id, name, description, type, status,
    and priority. Use search to find a project by (partial) name before calling
    get_project_tasks, which needs the project's numeric ID, not its name."""
    token = _auth_token(ctx)
    params: dict[str, Any] = {"page": page, "size": page_size}
    if search:
        params["search"] = search
    if status:
        params["status"] = status
    if business_id is not None:
        params["businessId"] = business_id
    if owner_user_id is not None:
        params["ownerUserId"] = owner_user_id
    try:
        return await call_abos_api(
            method="GET",
            path="/api/projects/event/project-list",
            auth_token=token,
            params=params,
        )
    except AbosApiError as exc:
        return _tool_error(exc)


@mcp_server.tool()
async def get_project_tasks(
    ctx: Context,
    project_id: Annotated[
        int,
        Field(description="Numeric ID of the project to fetch tasks/workitems for. Use list_projects first if you only know the project's name, not its ID."),
    ],
    status: Annotated[
        Optional[str],
        Field(description="Filter by task status, e.g. 'TODO', 'IN_PROGRESS', 'DONE'. Case-sensitive, must match exactly."),
    ] = None,
    root_only: Annotated[
        bool,
        Field(description="When true, only return top-level workitems (no sub-tasks). Defaults to false, which returns every workitem under the project at any nesting level."),
    ] = False,
    assigned_user_id: Annotated[
        Optional[int],
        Field(description="Filter to tasks assigned to this specific user's numeric ID."),
    ] = None,
    search: Annotated[
        Optional[str],
        Field(description="Case-insensitive substring to match against task title/description."),
    ] = None,
    page: Annotated[int, Field(description="Zero-based page index for pagination.", ge=0)] = 0,
    page_size: Annotated[
        int, Field(description="Number of tasks per page (1-100).", ge=1, le=100)
    ] = 20,
) -> dict[str, Any]:
    """List the tasks/workitems that belong to a specific project — title, description,
    type, status, priority, assignee, and due date. Always resolve the project name to its
    numeric ID with list_projects first; this tool requires the ID, not the name."""
    token = _auth_token(ctx)
    params: dict[str, Any] = {"page": page, "size": page_size, "projectId": project_id}
    if status:
        params["status"] = status
    if root_only:
        params["rootOnly"] = root_only
    if assigned_user_id is not None:
        params["assignedUserId"] = assigned_user_id
    if search:
        params["search"] = search
    try:
        return await call_abos_api(
            method="GET",
            path="/api/projects/workitems/event/workitem-list",
            auth_token=token,
            params=params,
        )
    except AbosApiError as exc:
        return _tool_error(exc)


# ============================================================================
# HRM — Employees
# ============================================================================

@mcp_server.tool()
async def search_employees(
    ctx: Context,
    query: Annotated[
        Optional[str],
        Field(description="Case-insensitive substring to match against employee first name, last name, or email. Omit to return employees regardless of name."),
    ] = None,
    status: Annotated[
        Optional[str],
        Field(description="Filter by employee status, e.g. 'ACTIVE', 'ON_LEAVE', 'TERMINATED'. Case-sensitive, must match exactly. Applied server-side by abos-api, unlike query."),
    ] = None,
    limit: Annotated[
        int, Field(description="Maximum number of matching employees to return (1-100).", ge=1, le=100)
    ] = 20,
) -> dict[str, Any]:
    """Search employees by name/email for the current tenant. abos-api's employee-list
    endpoint only supports server-side pagination and a status filter — there is no
    server-side name search — so this tool pages through employee-list (capped at 200
    records scanned, i.e. at most 2 pages of 100) and filters client-side by a
    case-insensitive substring match on first name, last name, or email. For a tenant with
    more than ~200 employees this may not scan the entire roster; pass status to narrow the
    server-side result set first whenever you can."""
    token = _auth_token(ctx)
    params: dict[str, Any] = {"page": 0, "size": 100}
    if status:
        params["status"] = status
    needle = query.lower() if query else None
    matches: list[dict[str, Any]] = []
    try:
        for page in range(2):  # cap: 100 * 2 = 200 employees scanned
            params["page"] = page
            page_result = await call_abos_api(
                method="GET",
                path="/api/hrm/employees/event/employee-list",
                auth_token=token,
                params=params,
            )
            content = page_result.get("content", []) if isinstance(page_result, dict) else (page_result or [])
            if not content:
                break
            for emp in content:
                first = emp.get("firstName") or ""
                last = emp.get("lastName") or ""
                email = emp.get("email") or ""
                if needle is None or needle in first.lower() or needle in last.lower() or needle in email.lower():
                    matches.append(
                        {
                            "id": emp.get("id"),
                            "firstName": first,
                            "lastName": last,
                            "email": email,
                            "jobTitle": emp.get("jobTitle"),
                            "department": emp.get("department"),
                            "status": emp.get("status"),
                        }
                    )
                    if len(matches) >= limit:
                        break
            if len(matches) >= limit:
                break
            if isinstance(page_result, dict) and page_result.get("last", True):
                break
    except AbosApiError as exc:
        return _tool_error(exc)
    return {"query": query, "employees": matches}
