# /apps/aroflo_connector_app/zones/tasks/mutations.py
from __future__ import annotations

from typing import Any, Dict, List, Tuple, Optional

from ..base import ZoneOperation, ParamSpec
from ._join_utils import request, raw_wrap

import json
import re

# -------------------------
# Operation codes
# -------------------------
OP_CREATE = "create_task"
OP_UPDATE = "update_task"
OP_MARK_LINKPROCESSED = "mark_task_linkprocessed"

OP_INSERT_NOTES = "insert_task_notes"
OP_INSERT_MATERIALS = "insert_task_adhoc_materials"
OP_UPDATE_SUBSTATUS = "update_task_substatus"
OP_UPDATE_PROJECT_STAGE = "update_task_project_stage"

# -------------------------
# Zone names
# -------------------------
# Nota: en ejemplos/documentación AroFlo aparece a veces "Tasks" y a veces "tasks".
# Algunos tenants pueden ser sensibles. Implementamos fallback seguro por error de zona.
AF_ZONE_TASKS_TITLE = "Tasks"
AF_ZONE_TASKS_LOWER = "tasks"

# -------------------------
# Join names
# -------------------------
# GET joins (lectura) suelen usar "material" (singular) en tasks join=material
AF_JOIN_MATERIALS_GET = "material"
# POST para insertar AdHoc material, la doc suele indicar join=materials (plural)
AF_JOIN_MATERIALS_POST = "materials"

# -------------------------
# Helpers
# -------------------------
def _cdata(s: Optional[str]) -> str:
    if s is None:
        return ""
    return f"<![CDATA[{s}]]>"


def _normalize_taskname(params: Dict[str, Any]) -> str:
    """
    Permite que el CLI use --summary y nosotros lo traduzcamos a taskname.
    También soporta taskname directamente si lo envían.
    """
    taskname = params.get("taskname")
    if taskname is None:
        taskname = params.get("summary")  # alias CLI
    if taskname is None or not str(taskname).strip():
        raise ValueError("taskname/summary no puede estar vacío.")
    return str(taskname)

_LIST_TOKEN_RE = re.compile(r"[,\s]+")

def _coerce_str_list(value: Any, *, field_name: str) -> List[str]:
    """
    Acepta:
    - list[str]
    - "id1" (single)
    - "[id1]" (string que parece lista)
    - '["id1","id2"]' (json)
    - "id1,id2" (csv)
    - "[id1,id2]" (brackets sin comillas)
    """
    if value is None:
        raise ValueError(f"{field_name} no puede ser vacío.")

    # Ya es lista
    if isinstance(value, list):
        out = [str(x).strip() for x in value if str(x).strip()]
        if not out:
            raise ValueError(f"{field_name} no puede ser una lista vacía.")
        return out

    # Si viene como string
    if isinstance(value, str):
        s = value.strip()

        # JSON list real: '["a","b"]'
        if s.startswith("[") and s.endswith("]"):
            # 1) intenta json.loads
            try:
                parsed = json.loads(s)
                if isinstance(parsed, list):
                    out = [str(x).strip() for x in parsed if str(x).strip()]
                    if not out:
                        raise ValueError(f"{field_name} no puede ser una lista vacía.")
                    return out
            except Exception:
                # 2) fallback: brackets sin comillas: "[a,b]" o "[a]"
                inner = s[1:-1].strip()
                if not inner:
                    raise ValueError(f"{field_name} no puede ser una lista vacía.")
                tokens = [t.strip().strip('"').strip("'") for t in _LIST_TOKEN_RE.split(inner) if t.strip()]
                if not tokens:
                    raise ValueError(f"{field_name} no puede ser una lista vacía.")
                return tokens

        # CSV "a,b"
        if "," in s:
            tokens = [t.strip() for t in s.split(",") if t.strip()]
            if not tokens:
                raise ValueError(f"{field_name} no puede ser vacío.")
            return tokens

        # single "a"
        if s:
            return [s]

    # Cualquier otro tipo
    raise ValueError(f"{field_name} debe ser una lista o un string convertible a lista.")


def _build_postxml_create(p: Dict[str, Any]) -> str:
    clientid = p["clientid"]
    tasktypeid = p["tasktypeid"]
    taskname = _normalize_taskname(p)

    taskname = str(taskname).strip()
    if len(taskname) > 50:
        taskname = taskname[:50]

    orgid = p.get("orgid")
    if not orgid:
        # En tu tenant ya vimos que es requerido
        raise ValueError("orgid es requerido para crear tasks en este tenant.")

    duedate = p.get("duedate")  # doc: YYYY-MM-DD o YYYY/MM/DD según tenant
    description = p.get("description")
    contactname = p.get("contactname")
    contactphone = p.get("contactphone")
    custon = p.get("custon")

    parts: List[str] = ["<tasks><task>"]

    # --- Forma 100% alineada con la doc (anidada) ---
    parts.append(f"<org><orgid>{orgid}</orgid></org>")
    parts.append(f"<client><clientid>{clientid}</clientid></client>")
    parts.append(f"<tasktype><tasktypeid>{tasktypeid}</tasktypeid></tasktype>")

    parts.append(f"<taskname>{_cdata(taskname)}</taskname>")

    if duedate:
        parts.append(f"<duedate>{duedate}</duedate>")

    if description:
        parts.append(f"<description>{_cdata(str(description))}</description>")
    if contactname:
        parts.append(f"<contactname>{_cdata(str(contactname))}</contactname>")
    if contactphone:
        parts.append(f"<contactphone>{_cdata(str(contactphone))}</contactphone>")
    if custon:
        parts.append(f"<custon>{_cdata(str(custon))}</custon>")

    parts.append("</task></tasks>")
    return "".join(parts)




def _build_postxml_update(p: Dict[str, Any]) -> str:
    taskid = p["taskid"]
    taskname = p.get("taskname")
    if taskname is None:
        taskname = p.get("summary")  # alias CLI
    status = p.get("status")

    if taskname is None and status is None:
        raise ValueError("Debes enviar al menos taskname/summary o status para actualizar.")

    parts: List[str] = ["<tasks><task>", f"<taskid>{taskid}</taskid>"]

    if taskname is not None:
        if not str(taskname).strip():
            raise ValueError("taskname/summary no puede ser vacío si se envía.")
        parts.append(f"<taskname>{_cdata(str(taskname))}</taskname>")

    if status is not None:
        if not str(status).strip():
            raise ValueError("status no puede ser vacío si se envía.")
        parts.append(f"<status>{_cdata(str(status))}</status>")

    parts.append("</task></tasks>")
    return "".join(parts)


def _build_postxml_linkprocessed(taskids: List[str]) -> str:
    if not taskids:
        raise ValueError("taskids no puede estar vacío.")
    parts: List[str] = ["<tasks>"]
    for tid in taskids:
        parts.append(f"<task><taskid>{tid}</taskid><linkprocessed>true</linkprocessed></task>")
    parts.append("</tasks>")
    return "".join(parts)


def _build_postxml_notes(taskid: str, notes: List[Dict[str, Any]]) -> str:
    if not notes:
        raise ValueError("notes no puede estar vacío.")

    parts: List[str] = [f"<tasks><task><taskid>{taskid}</taskid><notes>"]
    for n in notes:
        content = n.get("content")
        if not content or not str(content).strip():
            raise ValueError("Cada note debe tener content.")

        flt = n.get("filter")  # ej: "internal only"
        sticky = n.get("sticky", False)

        parts.append("<note>")
        parts.append(f"<content>{_cdata(str(content))}</content>")
        if flt is not None:
            parts.append(f"<filter>{_cdata(str(flt))}</filter>")
        parts.append(f"<sticky>{'true' if bool(sticky) else 'false'}</sticky>")
        parts.append("</note>")

    parts.append("</notes></task></tasks>")
    return "".join(parts)


def _build_postxml_materials(taskid: str, materials: List[Dict[str, Any]]) -> str:
    if not materials:
        raise ValueError("materials no puede estar vacío.")

    parts: List[str] = [f"<tasks><task><taskid>{taskid}</taskid><materials>"]
    for m in materials:
        item = m.get("item")
        if not item or not str(item).strip():
            raise ValueError("Cada material debe incluir item.")

        parts.append("<material>")
        if m.get("partnumber"):
            parts.append(f"<partnumber>{_cdata(str(m['partnumber']))}</partnumber>")
        parts.append(f"<item>{_cdata(str(item))}</item>")

        if m.get("cost") is not None:
            parts.append(f"<cost>{m['cost']}</cost>")
        if m.get("sell") is not None:
            parts.append(f"<sell>{m['sell']}</sell>")
        if m.get("dateused") is not None:
            parts.append(f"<dateused>{m['dateused']}</dateused>")  # doc: YYYY/MM/DD
        if m.get("quantity") is not None:
            parts.append(f"<quantity>{m['quantity']}</quantity>")

        parts.append("</material>")

    parts.append("</materials></task></tasks>")
    return "".join(parts)


def _build_postxml_update_substatus(taskid: str, status: str, substatusid: str) -> str:
    return (
        "<tasks><task>"
        f"<taskid>{taskid}</taskid>"
        f"<status>{_cdata(status)}</status>"
        f"<substatus><substatusid>{substatusid}</substatusid></substatus>"
        "</task></tasks>"
    )


def _build_postxml_update_project_stage(taskid: str, projectid: Optional[str], stageid: Optional[str]) -> str:
    if not projectid and not stageid:
        raise ValueError("Debes enviar projectid y/o stageid.")
    parts = ["<tasks><task>", f"<taskid>{taskid}</taskid>"]
    if projectid:
        parts.append(f"<project><projectid>{projectid}</projectid></project>")
    if stageid:
        parts.append(f"<stage><stageid>{stageid}</stageid></stage>")
    parts.append("</task></tasks>")
    return "".join(parts)


def _is_zone_error(resp: Any) -> bool:
    """
    Detecta error de "zone" de forma defensiva, sin asumir formato exacto.
    """
    try:
        if isinstance(resp, dict):
            # Campos comunes vistos: statusmessage, error, message
            for k in ("statusmessage", "error", "message"):
                v = resp.get(k)
                if isinstance(v, str) and "zone" in v.lower():
                    return True
        if isinstance(resp, str) and "zone" in resp.lower():
            return True
    except Exception:
        return False
    return False


def _post_with_zone_fallback(
    client: Any,
    *,
    primary_zone: str,
    alternate_zone: str,
    postxml: str,
    join: Optional[str] = None,
) -> Any:
    """
    Ejecuta POST y si falla por error de zona, reintenta una sola vez alternando el zone.
    """
    params_list: List[Tuple[str, str]] = [("zone", primary_zone)]
    if join:
        params_list.append(("join", join))
    params_list.append(("postxml", postxml))

    resp = request(client, "POST", params_list)
    if _is_zone_error(resp) and alternate_zone and alternate_zone != primary_zone:
        params_list2: List[Tuple[str, str]] = [("zone", alternate_zone)]
        if join:
            params_list2.append(("join", join))
        params_list2.append(("postxml", postxml))
        return request(client, "POST", params_list2)

    return resp


# -------------------------
# Operations registry
# -------------------------
def get_operations() -> List[ZoneOperation]:
    ops: List[ZoneOperation] = [
        ZoneOperation(
            code=OP_CREATE,
            label="Create Task",
            description="Crea una nueva task (POST zone=Tasks|tasks) usando postxml (fallback automático por error de zone).",
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={
                "raw": False,
                "dry_run": False,
                "orgid": None,
                "clientid": None,
                "tasktypeid": None,
                "taskname": None,  # o summary (alias)
                "summary": None,  # alias
                "duedate": None,  # YYYY/MM/DD
                "description": None,
                "contactname": None,
                "contactphone": None,
                "custon": None,
            },
            params=[
                ParamSpec("orgid", "string", False, "OrgID (opcional pero recomendado)."),
                ParamSpec("clientid", "string", True, "ClientID requerido por AroFlo."),
                ParamSpec("tasktypeid", "string", True, "TaskTypeID requerido."),
                ParamSpec("taskname", "string", False, "Nombre de la task (taskname)."),
                ParamSpec("summary", "string", False, "Alias de taskname (para CLI)."),
                ParamSpec("duedate", "string", False, "Fecha vencimiento (YYYY/MM/DD)."),
                ParamSpec("description", "string", False, "Descripción."),
                ParamSpec("contactname", "string", False, "Nombre contacto."),
                ParamSpec("contactphone", "string", False, "Tel contacto."),
                ParamSpec("custon", "string", False, "Campo custon (según doc)."),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Si true, devuelve respuesta cruda + meta debug."),
            ],
            category="tasks",
            use_cases=["Crear task de prueba", "Crear task desde automatización"],
            risk_level="high",
            requires_confirmation=True,
        ),
        ZoneOperation(
            code=OP_UPDATE,
            label="Update Task",
            description="Actualiza una task existente por taskid (POST zone=Tasks|tasks) usando postxml (fallback por zone).",
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={
                "raw": False,
                "dry_run": False,
                "taskid": None,
                "taskname": None,
                "summary": None,
                "status": None,
            },
            params=[
                ParamSpec("taskid", "string", True, "TaskID a actualizar."),
                ParamSpec("taskname", "string", False, "Nuevo taskname."),
                ParamSpec("summary", "string", False, "Alias de taskname (para CLI)."),
                ParamSpec("status", "string", False, "Nuevo status (ej: Pending)."),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Si true, devuelve respuesta cruda + meta debug."),
            ],
            category="tasks",
            use_cases=["Cambiar status", "Actualizar nombre"],
            risk_level="high",
            requires_confirmation=True,
        ),
        ZoneOperation(
            code=OP_MARK_LINKPROCESSED,
            label="Mark Task as linkprocessed",
            description="Marca linkprocessed=true en una o múltiples tasks (POST zone=Tasks|tasks) con fallback por zone.",
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={"raw": False, "dry_run": False},
            params=[
                ParamSpec("taskids", "array", True, "Lista de taskid a marcar linkprocessed=true.", items_schema={"type": "string"}),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Si true, devuelve respuesta cruda + meta debug."),
            ],
            category="tasks",
            use_cases=["Marcar tasks procesadas por sistema externo"],
            risk_level="medium",
            requires_confirmation=True,
        ),
        ZoneOperation(
            code=OP_INSERT_NOTES,
            label="Insert Notes to Task",
            description="Inserta una o múltiples notas en una task (POST zone=tasks|Tasks) con fallback por zone.",
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={"raw": False, "dry_run": False},
            params=[
                ParamSpec("taskid", "string", True, "TaskID destino."),
                ParamSpec(
                    "notes",
                    "array",
                    True,
                    "Lista de notas: [{content, filter, sticky}]",
                    items_schema={
                        "type": "object",
                        "properties": {
                            "content": {"type": "string", "description": "Contenido de la nota."},
                            "filter": {"type": "string", "description": "Filtro (ej: internal only)."},
                            "sticky": {"type": "boolean", "description": "Si la nota es sticky."},
                        },
                        "required": ["content"],
                        "additionalProperties": False,
                    },
                ),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Debug raw."),
            ],
            category="tasks",
            risk_level="medium",
            requires_confirmation=True,
        ),
        ZoneOperation(
            code=OP_INSERT_MATERIALS,
            label="Insert AdHoc material to Task",
            description=(
                "Inserta materiales AdHoc a una task (POST zone=tasks|Tasks join=materials). "
                "Nota: GET suele usar join=material, pero para este POST la doc suele pedir join=materials."
            ),
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={"raw": False, "dry_run": False},
            params=[
                ParamSpec("taskid", "string", True, "TaskID destino."),
                ParamSpec(
                    "materials",
                    "array",
                    True,
                    "Lista de materials AdHoc.",
                    items_schema={
                        "type": "object",
                        "properties": {
                            "partnumber": {"type": "string", "description": "Part number (opcional)."},
                            "item": {"type": "string", "description": "Nombre/ítem del material."},
                            "cost": {"type": "number", "description": "Costo (opcional)."},
                            "sell": {"type": "number", "description": "Precio de venta (opcional)."},
                            "dateused": {"type": "string", "description": "Fecha uso (YYYY/MM/DD)."},
                            "quantity": {"type": "number", "description": "Cantidad (opcional)."},
                        },
                        "required": ["item"],
                        "additionalProperties": False,
                    },
                ),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Debug raw."),
            ],
            category="tasks",
            risk_level="high",
            requires_confirmation=True,
        ),
        ZoneOperation(
            code=OP_UPDATE_SUBSTATUS,
            label="Update Task Substatus",
            description="Actualiza status y substatusid de una task (POST zone=tasks|Tasks) con fallback por zone.",
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={"raw": False, "dry_run": False},
            params=[
                ParamSpec("taskid", "string", True, "TaskID destino."),
                ParamSpec("status", "string", True, "Status (ej: pending)."),
                ParamSpec("substatusid", "string", True, "SubstatusID."),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Debug raw."),
            ],
            category="tasks",
            risk_level="high",
            requires_confirmation=True,
        ),
        ZoneOperation(
            code=OP_UPDATE_PROJECT_STAGE,
            label="Update Task Project/Stage",
            description="Actualiza projectid y/o stageid de una task (POST zone=Tasks|tasks) con fallback por zone.",
            http_method="POST",
            side_effect="write",
            idempotent=False,
            default_params={"raw": False, "dry_run": False},
            params=[
                ParamSpec("taskid", "string", True, "TaskID destino."),
                ParamSpec("projectid", "string", False, "ProjectID."),
                ParamSpec("stageid", "string", False, "StageID."),
                ParamSpec("dry_run", "boolean", False, "Si true, no ejecuta POST; devuelve preview (postxml/params)."),
                ParamSpec("raw", "boolean", False, "Debug raw."),
            ],
            category="tasks",
            risk_level="high",
            requires_confirmation=True,
        ),
    ]
    return ops


def supports(operation_code: str) -> bool:
    return operation_code in {
        OP_CREATE,
        OP_UPDATE,
        OP_MARK_LINKPROCESSED,
        OP_INSERT_NOTES,
        OP_INSERT_MATERIALS,
        OP_UPDATE_SUBSTATUS,
        OP_UPDATE_PROJECT_STAGE,
    }


# -------------------------
# Execute
# -------------------------
def execute(operation_code: str, client: Any, params: Dict[str, Any]) -> Any:
    raw = bool(params.get("raw", False))
    dry_run = bool(params.get("dry_run", False))

    def _preview(params_list: List[Tuple[str, str]], postxml: str, join: Optional[str] = None) -> Dict[str, Any]:
        return {
            "dry_run": True,
            "http_method": "POST",
            "params": params_list,   # <- antes
            "join": join,
            "postxml": postxml,
        }

    if operation_code == OP_CREATE:
        postxml = _build_postxml_create(params)
        params_list: List[Tuple[str, str]] = [("zone", AF_ZONE_TASKS_TITLE), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml)
        resp = _post_with_zone_fallback(client, primary_zone=AF_ZONE_TASKS_TITLE, alternate_zone=AF_ZONE_TASKS_LOWER, postxml=postxml)
        return raw_wrap(resp, params_list) if raw else resp

    if operation_code == OP_UPDATE:
        postxml = _build_postxml_update(params)
        params_list = [("zone", AF_ZONE_TASKS_TITLE), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml)
        resp = _post_with_zone_fallback(client, primary_zone=AF_ZONE_TASKS_TITLE, alternate_zone=AF_ZONE_TASKS_LOWER, postxml=postxml)
        return raw_wrap(resp, params_list) if raw else resp

    if operation_code == OP_MARK_LINKPROCESSED:
        taskids = _coerce_str_list(params.get("taskids"), field_name="taskids")
        postxml = _build_postxml_linkprocessed(taskids)

        params_list = [("zone", AF_ZONE_TASKS_TITLE), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml)
        resp = _post_with_zone_fallback(client, primary_zone=AF_ZONE_TASKS_TITLE, alternate_zone=AF_ZONE_TASKS_LOWER, postxml=postxml)
        return raw_wrap(resp, params_list) if raw else resp

    if operation_code == OP_INSERT_NOTES:
        taskid = params["taskid"]
        notes = params["notes"]
        if not isinstance(notes, list):
            raise ValueError("notes debe ser una lista.")
        postxml = _build_postxml_notes(taskid, notes)
        params_list = [("zone", AF_ZONE_TASKS_LOWER), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml)
        resp = _post_with_zone_fallback(client, primary_zone=AF_ZONE_TASKS_LOWER, alternate_zone=AF_ZONE_TASKS_TITLE, postxml=postxml)
        return raw_wrap(resp, params_list) if raw else resp

    if operation_code == OP_INSERT_MATERIALS:
        taskid = params["taskid"]
        materials = params["materials"]
        if not isinstance(materials, list):
            raise ValueError("materials debe ser una lista.")
        postxml = _build_postxml_materials(taskid, materials)
        params_list = [("zone", AF_ZONE_TASKS_LOWER), ("join", AF_JOIN_MATERIALS_POST), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml, join=AF_JOIN_MATERIALS_POST)
        resp = _post_with_zone_fallback(
            client,
            primary_zone=AF_ZONE_TASKS_LOWER,
            alternate_zone=AF_ZONE_TASKS_TITLE,
            postxml=postxml,
            join=AF_JOIN_MATERIALS_POST,
        )
        return raw_wrap(resp, params_list) if raw else resp

    if operation_code == OP_UPDATE_SUBSTATUS:
        postxml = _build_postxml_update_substatus(params["taskid"], params["status"], params["substatusid"])
        params_list = [("zone", AF_ZONE_TASKS_LOWER), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml)
        resp = _post_with_zone_fallback(client, primary_zone=AF_ZONE_TASKS_LOWER, alternate_zone=AF_ZONE_TASKS_TITLE, postxml=postxml)
        return raw_wrap(resp, params_list) if raw else resp

    if operation_code == OP_UPDATE_PROJECT_STAGE:
        postxml = _build_postxml_update_project_stage(params["taskid"], params.get("projectid"), params.get("stageid"))
        params_list = [("zone", AF_ZONE_TASKS_TITLE), ("postxml", postxml)]
        if dry_run:
            return _preview(params_list, postxml)
        resp = _post_with_zone_fallback(client, primary_zone=AF_ZONE_TASKS_TITLE, alternate_zone=AF_ZONE_TASKS_LOWER, postxml=postxml)
        return raw_wrap(resp, params_list) if raw else resp

    raise ValueError(f"[Tasks.mutations] Operación no soportada: {operation_code}")
