#!/usr/bin/env python3
"""
clickup_client.py — ClickUp V2 API client for the Winston quote pipeline (DVI-815).

Handles:
  - Task discovery filtered by date (date_updated, date_created, or due_date)
  - File attachment upload to a task
  - Custom field value set (Quote Amount write-back)
  - Attachment existence check for deduplication
"""

import logging
import re
from datetime import datetime, timezone

import requests

log = logging.getLogger("togen.clickup_client")

_CLICKUP_BASE = "https://api.clickup.com/api/v2"

# Quote number pattern: 26-1906X, 26-1827_v2, 26-2904, etc.
_QUOTE_RE = re.compile(r"(\d{2}-\d{4}[A-Z0-9]*(?:_v\d+)?)")


def strip_version_suffix(tag: str) -> str:
    """Strip trailing version suffix (v1–v99, case-insensitive) from a tag.

    Mirrors PoC strip_version_suffix: 'xv2' → 'x', 'SHv1' → 'SH'.
    """
    return re.sub(r"v\d{1,2}$", "", tag, flags=re.IGNORECASE)


def _raw_tag_names(task: dict) -> list[str]:
    """Tag names on THIS task only (not subtasks). Used for DEBUG logging so
    the raw ClickUp `tags` field is visible (DVI-994 point 2)."""
    return [(t.get("name") or "").strip()
            for t in (task.get("tags") or []) if (t.get("name") or "").strip()]


def _collect_tags(task: dict) -> list[str]:
    """Collect tag names from a task and all its subtasks recursively.

    Mirrors PoC _collect_tags: deduplicates, preserves order.
    Tags are returned as-is (not version-stripped) so callers can log
    the raw value and strip on demand.

    NOTE (DVI-994): the ClickUp list/team task endpoints return subtasks as
    FLAT sibling entries, not nested under a parent's ``subtasks`` key, so this
    recursion only sees subtasks that a prior per-task detail fetch nested in
    (see :meth:`ClickUpClient._nest_subtask_tags`). The quote-type tags
    (``xv1``/``shv2``/…) live on those subtasks, so without nesting this returns
    ``[]`` for every parent quote task.
    """
    tags: list[str] = []
    for t in task.get("tags") or []:
        name = (t.get("name") or "").strip()
        if name:
            tags.append(name)
    for sub in task.get("subtasks") or []:
        tags.extend(_collect_tags(sub))
    # Deduplicate while preserving first-seen order.
    seen: set[str] = set()
    out: list[str] = []
    for tag in tags:
        if tag not in seen:
            seen.add(tag)
            out.append(tag)
    return out


def _ms_to_iso(ms) -> str | None:
    """Convert a ClickUp epoch-millisecond timestamp (str|int) to an ISO-8601
    UTC string, or None when absent/unparseable. DVI-972 (diagnostics only)."""
    if ms in (None, "", 0, "0"):
        return None
    try:
        return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc).isoformat()
    except (ValueError, TypeError, OSError):
        return None


def _resolve_cf_value(cf: dict):
    """Return a human-readable value for a ClickUp custom field, resolving
    drop_down/labels ids to their option names. DVI-972 (diagnostics only)."""
    if not isinstance(cf, dict):
        return None
    value = cf.get("value")
    if value is None:
        return None
    cf_type = cf.get("type", "")
    cfg = cf.get("type_config") or {}
    try:
        if cf_type == "drop_down":
            options = cfg.get("options") or []
            for opt in options:
                if opt.get("orderindex") == value or opt.get("id") == value:
                    return opt.get("name", value)
            return value
        if cf_type == "labels" and isinstance(value, list):
            by_id = {o.get("id"): o.get("label", o.get("name", ""))
                     for o in (cfg.get("options") or [])}
            return [by_id.get(v, v) for v in value]
        if cf_type in ("users",) and isinstance(value, list):
            return [(u.get("username") or u.get("email") or "") for u in value]
    except (AttributeError, TypeError):
        return value
    return value


class ClickUpClient:
    """Minimal ClickUp V2 client for the Winston quote pipeline."""

    def __init__(self, api_key: str):
        if not api_key:
            raise ValueError("ClickUp API key is required")
        self._key = api_key
        self._json_headers = {
            "Authorization": api_key,
            "Content-Type": "application/json",
        }
        self._bare_headers = {"Authorization": api_key}

    # -------------------------------------------------------------------------
    # Task discovery
    # -------------------------------------------------------------------------

    def get_quote_tasks(
        self,
        list_id: str,
        target_date: str,
        *,
        date_field: str = "date_updated",
        include_closed: bool = True,
    ) -> list[dict]:
        """Return normalized quote-task dicts for tasks matching target_date.

        target_date : 'YYYY-MM-DD'
        date_field  : 'date_updated' | 'date_created' | 'due_date'
        """
        d = datetime.fromisoformat(target_date)
        start_ms = int(
            datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000
        )
        end_ms = int(
            datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc).timestamp() * 1000
        )

        raw_tasks: list[dict] = []
        page = 0
        while True:
            params: dict[str, object] = {
                f"{date_field}_gt": start_ms - 1,
                f"{date_field}_lt": end_ms + 1,
                "subtasks": "true",
                "page": page,
            }
            if include_closed:
                params["include_closed"] = "true"

            try:
                r = requests.get(
                    f"{_CLICKUP_BASE}/list/{list_id}/task",
                    headers=self._json_headers,
                    params=params,
                    timeout=30,
                )
            except Exception:
                log.exception("ClickUp get_quote_tasks request error (page %d)", page)
                break

            if r.status_code != 200:
                log.error(
                    "ClickUp get_tasks failed (%s) list=%s date=%s: %s",
                    r.status_code,
                    list_id,
                    target_date,
                    r.text[:300],
                )
                break

            data = r.json()
            batch = data.get("tasks", [])
            raw_tasks.extend(batch)
            if data.get("last_page", True) or not batch:
                break
            page += 1

        log.info(
            "ClickUp: %d task(s) found for list=%s date=%s field=%s",
            len(raw_tasks),
            list_id,
            target_date,
            date_field,
        )
        # DVI-994: nest subtask tags (parent tasks carry no tags themselves).
        self._nest_subtask_tags(raw_tasks)
        return [self._parse_task(t) for t in raw_tasks]

    def _parse_task(self, task: dict) -> dict:
        """Normalize a raw ClickUp task into the fields the pipeline needs."""
        name = task.get("name", "")
        m = _QUOTE_RE.search(name)
        quote_number = m.group(1) if m else name.strip()

        # Build a name→cf dict for convenience
        cf_by_name: dict[str, dict] = {
            cf.get("name", "").lower(): cf for cf in task.get("custom_fields", [])
        }

        recipient_email = self._cf_str(cf_by_name, "email", "recipient", "customer email")
        quote_amount_field_id = self._cf_id(cf_by_name, "amount", "quote amount", "total")
        owner_email = self._cf_str(cf_by_name, "salesperson", "owner", "sales rep")

        if not owner_email:
            assignees = task.get("assignees", [])
            if assignees:
                owner_email = assignees[0].get("email", "")

        # DVI-972: capture richer read-only context for the Diagnostics pane
        # (does not affect pipeline behavior — purely additive fields).
        status_obj = task.get("status") or {}
        status_name = status_obj.get("status", "") if isinstance(status_obj, dict) else ""
        amount_value = self._cf_value(cf_by_name, "amount", "quote amount", "total")
        assignees = [
            (a.get("username") or a.get("email") or "").strip()
            for a in (task.get("assignees") or [])
        ]
        assignees = [a for a in assignees if a]

        # DVI-981: collect tags from this task + all subtasks recursively (mirrors
        # PoC _collect_tags). Tags are the signal that selects the quote sheet.
        tags = _collect_tags(task)

        return {
            "quote_number": quote_number,
            "clickup_task_id": task.get("id", ""),
            "task_name": name,
            "recipient_email": recipient_email,
            "owner_email": owner_email,
            "quote_amount_field_id": quote_amount_field_id,
            # --- DVI-972 diagnostics-only extras ---
            "url": task.get("url", ""),
            "status": status_name,
            "amount_value": amount_value,
            "assignees": assignees,
            "date_created": _ms_to_iso(task.get("date_created")),
            "date_updated": _ms_to_iso(task.get("date_updated")),
            "due_date": _ms_to_iso(task.get("due_date")),
            "custom_fields": self._custom_fields_readable(task.get("custom_fields", [])),
            # DVI-981: raw tags (incl. subtask tags); base tags = strip_version_suffix applied
            "tags": tags,
        }

    def _cf_str(self, fields: dict[str, dict], *keywords: str) -> str:
        """Return value of the first custom field whose name contains any keyword."""
        for kw in keywords:
            for name, cf in fields.items():
                if kw in name:
                    v = cf.get("value")
                    if v and isinstance(v, str):
                        return v
        return ""

    def _cf_id(self, fields: dict[str, dict], *keywords: str) -> str:
        """Return id of the first custom field whose name contains any keyword."""
        for kw in keywords:
            for name, cf in fields.items():
                if kw in name:
                    return cf.get("id", "")
        return ""

    def _cf_value(self, fields: dict[str, dict], *keywords: str):
        """Return the resolved value of the first custom field matching a keyword."""
        for kw in keywords:
            for name, cf in fields.items():
                if kw in name:
                    return _resolve_cf_value(cf)
        return None

    def _custom_fields_readable(self, custom_fields: list) -> list[dict]:
        """DVI-972: normalize all custom fields to [{name, value}] with human-
        readable values (drop_down/labels resolved), skipping empty values."""
        out: list[dict] = []
        for cf in custom_fields or []:
            val = _resolve_cf_value(cf)
            if val in (None, "", []):
                continue
            out.append({"name": cf.get("name", ""), "value": val})
        return out

    # -------------------------------------------------------------------------
    # DVI-999: any-depth recursive subtask-tag nesting
    # -------------------------------------------------------------------------

    def _nest_subtask_tags(self, raw_tasks: list[dict]) -> None:
        """Mutate raw_tasks so each task carries its full nested subtask tree.

        ANY-DEPTH recursive fetch (DVI-999 / PoC fetch_subtask_names): for tasks
        with no own tags, calls GET /task/{id}?include_subtasks=true (nests one
        level), then recursively fetches each subtask's detail so _collect_tags
        can reach tags at any depth. Uses a visited set seeded with all top-level
        task IDs to avoid cycles; depth capped at 10.
        """
        visited: set[str] = {t.get("id", "") for t in raw_tasks if t.get("id")}
        for t in raw_tasks:
            t_id = t.get("id", "")
            own = _raw_tag_names(t)
            if own:
                log.debug(
                    "ClickUp task %s: own tags=%s (no subtask fetch needed)",
                    t_id, own,
                )
                continue
            detail = self.get_task_detail(t_id) if t_id else None
            nested = (detail or {}).get("subtasks") or []
            if nested:
                t["subtasks"] = nested
                for sub in nested:
                    self._fetch_subtask_tree(sub, visited, depth=1)
            sub_tags = _collect_tags({"subtasks": t.get("subtasks", [])})
            log.debug(
                "ClickUp task %s: own_tags=[] nested_subtasks=%d subtask_tags=%s",
                t_id, len(nested), sub_tags,
            )

    def _fetch_subtask_tree(self, subtask: dict, visited: set[str], depth: int) -> None:
        """Recursively fetch and attach full detail (tags + children) for a subtask.

        PoC parity (DVI-999): each level of subtasks may itself have subtasks
        carrying the quote-type tags, so we drill down until depth>=10 or the
        visited set closes the cycle.
        """
        if depth >= 10:
            return
        sub_id = subtask.get("id", "")
        if not sub_id or sub_id in visited:
            return
        visited.add(sub_id)
        detail = self.get_task_detail(sub_id)
        if not detail:
            return
        subtask["tags"] = detail.get("tags") or subtask.get("tags") or []
        children = detail.get("subtasks") or []
        subtask["subtasks"] = children
        for child in children:
            self._fetch_subtask_tree(child, visited, depth + 1)

    def get_task_detail(self, task_id: str) -> dict | None:
        """GET a single task with nested subtasks (`include_subtasks=true`).

        Returns the raw task dict or None on error. Unlike the list/team task
        endpoints, this nests subtasks under the `subtasks` key.
        """
        if not task_id:
            return None
        try:
            r = requests.get(
                f"{_CLICKUP_BASE}/task/{task_id}",
                headers=self._json_headers,
                params={"include_subtasks": "true"},
                timeout=30,
            )
        except Exception:
            log.exception("ClickUp get_task_detail request error task=%s", task_id)
            return None
        if r.status_code != 200:
            log.error(
                "ClickUp get_task_detail failed (%s) task=%s: %s",
                r.status_code, task_id, r.text[:300],
            )
            return None
        return r.json()

    def get_quote_tasks_by_team(
        self,
        team_id: str,
        target_date: str,
        *,
        date_field: str = "date_updated",
        include_closed: bool = True,
    ) -> list[dict]:
        """Return normalized quote-task dicts queried from a whole team/workspace.

        Uses GET /team/{team_id}/task — matches the PoC (outlook_auto) approach
        when no specific list_id is configured. Filters to tasks whose names
        contain a recognizable quote number pattern to exclude unrelated workspace tasks.

        target_date : 'YYYY-MM-DD'
        date_field  : 'due_date' | 'date_updated' | 'date_created'
        """
        d = datetime.fromisoformat(target_date)
        start_ms = int(
            datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc).timestamp() * 1000
        )
        end_ms = int(
            datetime(d.year, d.month, d.day, 23, 59, 59, tzinfo=timezone.utc).timestamp() * 1000
        )

        raw_tasks: list[dict] = []
        page = 0
        while True:
            params: dict[str, object] = {
                f"{date_field}_gt": start_ms - 1,
                f"{date_field}_lt": end_ms + 1,
                "subtasks": "true",
                "page": page,
            }
            if include_closed:
                params["include_closed"] = "true"

            try:
                r = requests.get(
                    f"{_CLICKUP_BASE}/team/{team_id}/task",
                    headers=self._json_headers,
                    params=params,
                    timeout=30,
                )
            except Exception:
                log.exception("ClickUp get_quote_tasks_by_team request error (page %d)", page)
                break

            if r.status_code != 200:
                log.error(
                    "ClickUp get_tasks_by_team failed (%s) team=%s date=%s: %s",
                    r.status_code,
                    team_id,
                    target_date,
                    r.text[:300],
                )
                break

            data = r.json()
            batch = data.get("tasks", [])
            raw_tasks.extend(batch)
            if data.get("last_page", True) or not batch:
                break
            page += 1

        # Filter to tasks whose names match the quote-number pattern before parsing,
        # so team-wide queries don't pull in unrelated workspace tasks.
        quote_tasks = [t for t in raw_tasks if _QUOTE_RE.search(t.get("name", ""))]
        log.info(
            "ClickUp: %d task(s) found for team=%s date=%s field=%s (%d total before quote filter)",
            len(quote_tasks),
            team_id,
            target_date,
            date_field,
            len(raw_tasks),
        )
        # DVI-994: nest subtask tags (parent tasks carry no tags themselves).
        self._nest_subtask_tags(quote_tasks)
        return [self._parse_task(t) for t in quote_tasks]

    # -------------------------------------------------------------------------
    # File attachment
    # -------------------------------------------------------------------------

    def attach_file(
        self,
        task_id: str,
        filename: str,
        content: bytes,
        content_type: str = "application/pdf",
    ) -> bool:
        """Attach a file to a ClickUp task. Returns True on success."""
        try:
            r = requests.post(
                f"{_CLICKUP_BASE}/task/{task_id}/attachment",
                headers=self._bare_headers,
                files={"attachment": (filename, content, content_type)},
                timeout=60,
            )
        except Exception:
            log.exception("ClickUp attach_file error task=%s", task_id)
            return False

        if r.status_code not in (200, 201):
            log.error(
                "ClickUp attach_file failed (%s) task=%s: %s",
                r.status_code,
                task_id,
                r.text[:300],
            )
            return False
        return True

    def attachment_exists(self, task_id: str, filename: str) -> bool:
        """Return True if an attachment with this filename already exists on the task."""
        try:
            r = requests.get(
                f"{_CLICKUP_BASE}/task/{task_id}",
                headers=self._json_headers,
                timeout=30,
            )
            if r.status_code == 200:
                attachments = r.json().get("attachments", [])
                return any(
                    a.get("title") == filename or a.get("file_name") == filename
                    for a in attachments
                )
        except Exception:
            log.exception("ClickUp attachment_exists error task=%s", task_id)
        return False

    # -------------------------------------------------------------------------
    # Custom field write-back
    # -------------------------------------------------------------------------

    def set_custom_field(self, task_id: str, field_id: str, value: object) -> bool:
        """Set a custom field value on a task. Returns True on success."""
        try:
            r = requests.post(
                f"{_CLICKUP_BASE}/task/{task_id}/field/{field_id}",
                headers=self._json_headers,
                json={"value": value},
                timeout=30,
            )
        except Exception:
            log.exception(
                "ClickUp set_custom_field error task=%s field=%s", task_id, field_id
            )
            return False

        if r.status_code not in (200, 201):
            log.error(
                "ClickUp set_custom_field failed (%s) task=%s field=%s: %s",
                r.status_code,
                task_id,
                field_id,
                r.text[:300],
            )
            return False
        return True
