"""LLM response parsing for the Strategy Actor.

Parses raw LLM output into structured action dicts, supporting both
JSON array and numbered/bulleted-list formats.

Forgejo: #828
"""

from __future__ import annotations

import json
import math
import re
from typing import Any

import structlog

logger = structlog.get_logger(__name__)

__all__ = [
    "parse_strategy_response",
]

_COMPLEXITY_VALUES = frozenset({"low", "medium", "high"})
_DEFAULT_DESCRIPTION = "Complete the plan objectives"
_MAX_ACTIONS = 500
_MAX_JSON_PARSE_RETRIES = 10
_MAX_GLOBAL_JSON_ATTEMPTS = 50


def parse_strategy_response(
    llm_output: str,
) -> list[dict[str, Any]]:
    """Parse an LLM response into a list of strategy action dicts.

    Attempts JSON parsing first; falls back to numbered-list heuristic
    parsing for non-JSON responses.

    Args:
        llm_output: Raw text from the LLM.

    Returns:
        List of dicts with keys: description, resource_requirements,
        estimated_complexity, risk_score, depends_on.
    """
    if not llm_output or not llm_output.strip():
        return [_default_action()]

    # Try JSON extraction first
    json_actions = _try_parse_json(llm_output)
    if json_actions is not None:
        return json_actions if json_actions else [_default_action()]

    # Fallback: parse numbered list
    return _parse_numbered_list(llm_output)


def _try_parse_json(text: str) -> list[dict[str, Any]] | None:
    """Attempt to extract a JSON array from the LLM output.

    Searches for ``[{`` anchors left-to-right, trying each as a
    candidate start position.  For each anchor the parser scans ``]``
    candidates right-to-left.  This handles LLM preamble text that
    contains ``[{`` fragments before the real JSON array.
    """
    end = text.rfind("]")
    if end == -1:
        return None

    # Collect all ``[{`` anchor positions so we can retry from the
    # next one when the first anchor turns out to be a false match
    # (e.g. LLM preamble containing ``[{…}]`` before the real array).
    anchors: list[int] = []
    pos = 0
    while True:
        idx = text.find("[{", pos)
        if idx == -1:
            break
        anchors.append(idx)
        pos = idx + 1
    # Fallback: plain ``[`` if no ``[{`` found at all.
    if not anchors:
        start = text.find("[")
        if start == -1 or end <= start:
            return None
        anchors = [start]

    parsed: Any = None
    global_attempts = 0
    for start in anchors:
        if start >= end:
            continue
        candidate = end
        retries = 0
        while candidate > start and retries < _MAX_JSON_PARSE_RETRIES:
            if global_attempts >= _MAX_GLOBAL_JSON_ATTEMPTS:
                break
            json_str = text[start : candidate + 1]
            try:
                parsed = json.loads(json_str)
                break
            except json.JSONDecodeError:
                candidate = text.rfind("]", start, candidate)
                retries += 1
                global_attempts += 1
        if parsed is not None:
            break
        if global_attempts >= _MAX_GLOBAL_JSON_ATTEMPTS:
            break
    if parsed is None:
        return None

    if not isinstance(parsed, list):
        return None
    if len(parsed) == 0:
        return []

    actions: list[dict[str, Any]] = []
    for item in parsed[:_MAX_ACTIONS]:
        if not isinstance(item, dict):
            continue
        raw_desc = item.get("description")
        desc = str(raw_desc).strip() if raw_desc is not None else ""
        if not desc:
            logger.debug(
                "Dropping JSON action item with empty description",
                item_keys=list(item.keys()),
            )
            continue
        complexity = str(item.get("estimated_complexity", "medium")).lower()
        if complexity not in _COMPLEXITY_VALUES:
            complexity = "medium"
        raw_risk = item.get("risk_score", 0.3)
        try:
            risk = float(raw_risk)
        except (TypeError, ValueError):
            risk = 0.3
        if math.isnan(risk) or math.isinf(risk):
            risk = 0.3
        risk = max(0.0, min(1.0, risk))

        raw_resources = item.get("resource_requirements", [])
        resources: list[str] = []
        if isinstance(raw_resources, list):
            resources = [str(r) for r in raw_resources if r]

        raw_deps = item.get("depends_on", [])
        deps: list[int] = []
        if isinstance(raw_deps, list):
            for d in raw_deps:
                try:
                    deps.append(int(d))
                except (TypeError, ValueError):
                    logger.debug(
                        "Dropping non-integer dependency value %r in action '%s'",
                        d,
                        desc[:60],
                    )

        action_dict: dict[str, Any] = {
            "description": desc,
            "resource_requirements": resources,
            "estimated_complexity": complexity,
            "risk_score": risk,
            "depends_on": deps,
        }
        # Preserve the ``step`` field when present so that _build_tree
        # can use it as the id_map key for dependency resolution.
        raw_step = item.get("step")
        if raw_step is not None:
            action_dict["step"] = raw_step
        actions.append(action_dict)

    if len(parsed) > _MAX_ACTIONS:
        logger.warning(
            "LLM returned %d actions, truncated to %d",
            len(parsed),
            _MAX_ACTIONS,
        )

    return actions if actions else []


def _parse_numbered_list(text: str) -> list[dict[str, Any]]:
    """Fall back to parsing numbered/bulleted lines from LLM output.

    Only lines that begin with a recognised numbered prefix (e.g.
    ``1.``, ``2)``) or bullet marker (``-``, ``*``, ``•``) are
    accepted as action items.  Preamble text and other non-action
    lines are silently skipped to avoid polluting the strategy tree.
    """
    lines = text.strip().splitlines()
    actions: list[dict[str, Any]] = []
    for line in lines:
        cleaned = line.strip()
        if not cleaned:
            continue
        recognised = False
        # Strip numbered prefix
        match = re.match(r"^\d+[\.\)\-\:]\s*", cleaned)
        if match:
            cleaned = cleaned[match.end() :].strip()
            recognised = True
        # Strip bullet prefixes
        if not recognised:
            for prefix in ("-", "*", "\u2022"):
                if cleaned.startswith(prefix):
                    cleaned = cleaned[len(prefix) :].strip()
                    recognised = True
                    break
        if recognised and cleaned:
            actions.append(_default_action(cleaned))

    return actions if actions else [_default_action()]


def _default_action(
    description: str = _DEFAULT_DESCRIPTION,
) -> dict[str, Any]:
    """Return a default action dict."""
    return {
        "description": description,
        "resource_requirements": [],
        "estimated_complexity": "medium",
        "risk_score": 0.3,
        "depends_on": [],
    }
