"""Recursive-descent parser for the workout DSL.

Grammar
-------
::

    workout    := step (',' step)*
    step       := repeat | single
    repeat     := INT 'x' ( '(' step_list ')' | single )
    step_list  := single ('+' single)*
    single     := duration? target?
    duration   := NUMBER UNIT | keyword
    UNIT       := 'min' | 'sec' | 's' | 'km' | 'm'
    keyword    := 'warmup' | 'cooldown' | 'open'
    target     := '@' ( pace_range | zone_name )
    pace_range := MM:SS ('-' MM:SS)?
    zone_name  := 'Z' DIGIT | named_zone

Examples
--------
- ``10min@Z2``
- ``warmup@6:00``
- ``3x8min@threshold``
- ``3x(8min@5:00+2min@6:00)``
- ``5km@5:30-6:00``
- ``cooldown@6:30``
- ``10min@Z2,3x8min@threshold,cooldown@6:30``
"""

from __future__ import annotations

import re
from dataclasses import dataclass

from pace2fit.model import (
    DurationType,
    IntensityType,
    PaceTarget,
    RepeatStep,
    Step,
    Workout,
    WorkoutStep,
)
from pace2fit.zones import pace_str_to_speed, resolve_named_zone


class ParseError(Exception):
    """Raised when the workout string cannot be parsed."""

    def __init__(self, message: str, pos: int | None = None):
        self.pos = pos
        super().__init__(message)


# ---------------------------------------------------------------------------
# Tokeniser
# ---------------------------------------------------------------------------

# Order matters — longer patterns first, more specific before general.
_TOKEN_PATTERNS: list[tuple[str, str]] = [
    ("PACE", r"\d{1,2}:\d{2}"),  # e.g. 5:30
    ("NUMBER", r"\d+(?:\.\d+)?"),  # e.g. 10, 3.5
    ("KEYWORD", r"(?:warmup|cooldown|open)\b"),  # keywords (word boundary)
    ("UNIT", r"(?:min|sec|km)\b|(?:m|s)\b"),  # duration units (word boundary)
    ("ZONE", r"[Zz]\d\b"),  # Z1 .. Z5
    ("TIMES", r"x(?=\s*[\d(])"),  # 'x' only before digit or '('
    ("NAME", r"[a-zA-Z]\w*"),  # named zones: easy, threshold, ...
    ("AT", r"@"),
    ("COMMA", r","),
    ("PLUS", r"\+"),
    ("LPAREN", r"\("),
    ("RPAREN", r"\)"),
    ("DASH", r"-"),
    ("WS", r"\s+"),
]

_TOKEN_RE = re.compile("|".join(f"(?P<{n}>{p})" for n, p in _TOKEN_PATTERNS))


@dataclass
class Token:
    kind: str
    value: str
    pos: int


def _tokenize(text: str) -> list[Token]:
    tokens: list[Token] = []
    for m in _TOKEN_RE.finditer(text):
        kind = m.lastgroup
        assert kind is not None
        if kind == "WS":
            continue
        tokens.append(Token(kind=kind, value=m.group(), pos=m.start()))
    return tokens


# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------


class _Parser:
    """Recursive-descent parser over a token stream."""

    def __init__(self, tokens: list[Token], raw: str):
        self._tokens = tokens
        self._raw = raw
        self._pos = 0

    # -- helpers -------------------------------------------------------------

    def _peek(self) -> Token | None:
        if self._pos < len(self._tokens):
            return self._tokens[self._pos]
        return None

    def _advance(self) -> Token:
        tok = self._tokens[self._pos]
        self._pos += 1
        return tok

    def _expect(self, kind: str) -> Token:
        tok = self._peek()
        if tok is None or tok.kind != kind:
            expected = kind
            got = tok.value if tok else "end of input"
            pos = tok.pos if tok else len(self._raw)
            raise ParseError(f"Expected {expected}, got '{got}'", pos=pos)
        return self._advance()

    def _match(self, kind: str) -> Token | None:
        tok = self._peek()
        if tok is not None and tok.kind == kind:
            return self._advance()
        return None

    # -- grammar rules -------------------------------------------------------

    def parse_workout(self) -> list[Step]:
        """workout := step (',' step)*"""
        steps = [self._parse_step()]
        while self._match("COMMA"):
            steps.append(self._parse_step())
        if self._peek() is not None:
            tok = self._peek()
            assert tok is not None
            raise ParseError(f"Unexpected token '{tok.value}'", pos=tok.pos)
        return steps

    def _parse_step(self) -> Step:
        """step := repeat | single"""
        # Lookahead: NUMBER 'x' means a repeat.
        if self._is_repeat():
            return self._parse_repeat()
        return self._parse_single()

    def _is_repeat(self) -> bool:
        """Check if current position starts a repeat: NUMBER 'x' ..."""
        if self._pos + 1 >= len(self._tokens):
            return False
        return (
            self._tokens[self._pos].kind == "NUMBER"
            and self._tokens[self._pos + 1].kind == "TIMES"
        )

    def _parse_repeat(self) -> RepeatStep:
        """repeat := INT 'x' ( '(' step_list ')' | single )"""
        count_tok = self._expect("NUMBER")
        count = int(count_tok.value)
        if count < 1:
            raise ParseError(
                f"Repeat count must be >= 1, got {count}", pos=count_tok.pos
            )
        self._expect("TIMES")

        if self._match("LPAREN"):
            steps = self._parse_step_list()
            self._expect("RPAREN")
        else:
            steps = [self._parse_single()]

        return RepeatStep(count=count, steps=steps)

    def _parse_step_list(self) -> list[WorkoutStep]:
        """step_list := single ('+' single)*"""
        steps = [self._parse_single()]
        while self._match("PLUS"):
            steps.append(self._parse_single())
        return steps

    def _parse_single(self) -> WorkoutStep:
        """single := duration? target?

        At least one of duration or target must be present.
        """
        duration_type, duration_value, intensity = self._try_parse_duration()
        target = self._try_parse_target()

        if duration_type is None and target is None:
            tok = self._peek()
            pos = tok.pos if tok else len(self._raw)
            raise ParseError("Expected a duration or target", pos=pos)

        # Default to open duration if only a target was given
        if duration_type is None:
            duration_type = DurationType.OPEN

        return WorkoutStep(
            duration_type=duration_type,
            duration_value=duration_value,
            target=target,
            intensity=intensity,
        )

    def _try_parse_duration(
        self,
    ) -> tuple[DurationType | None, float | None, IntensityType]:
        """Try to parse a duration. Returns (type, value, intensity).

        Returns ``(None, None, ACTIVE)`` if no duration is found.
        """
        tok = self._peek()
        if tok is None:
            return None, None, IntensityType.ACTIVE

        # keyword: warmup, cooldown, open
        if tok.kind == "KEYWORD":
            self._advance()
            kw = tok.value.lower()
            if kw == "warmup":
                return DurationType.OPEN, None, IntensityType.WARMUP
            if kw == "cooldown":
                return DurationType.OPEN, None, IntensityType.COOLDOWN
            if kw == "open":
                return DurationType.OPEN, None, IntensityType.ACTIVE
            # shouldn't happen given our token regex
            raise ParseError(f"Unknown keyword '{kw}'", pos=tok.pos)

        # NUMBER UNIT  (e.g. 10min, 5km, 400m)
        if tok.kind == "NUMBER":
            # Only consume if followed by a UNIT (otherwise it might be a repeat count)
            next_tok = (
                self._tokens[self._pos + 1]
                if self._pos + 1 < len(self._tokens)
                else None
            )
            if next_tok is not None and next_tok.kind == "UNIT":
                num_tok = self._advance()
                unit_tok = self._advance()
                value = float(num_tok.value)
                unit = unit_tok.value

                if unit == "min":
                    return DurationType.TIME, value * 60, IntensityType.ACTIVE
                if unit in ("sec", "s"):
                    return DurationType.TIME, value, IntensityType.ACTIVE
                if unit == "km":
                    return DurationType.DISTANCE, value * 1000, IntensityType.ACTIVE
                if unit == "m":
                    return DurationType.DISTANCE, value, IntensityType.ACTIVE

        return None, None, IntensityType.ACTIVE

    def _try_parse_target(self):
        """Try to parse ``@target``. Returns a Target or None."""
        if not self._match("AT"):
            return None

        tok = self._peek()
        if tok is None:
            raise ParseError("Expected a target after '@'", pos=len(self._raw))

        # Pace: M:SS or M:SS-M:SS
        if tok.kind == "PACE":
            return self._parse_pace_target()

        # HR zone: Z1 .. Z5
        if tok.kind == "ZONE":
            zone_tok = self._advance()
            return resolve_named_zone(zone_tok.value)

        # Named zone: easy, threshold, tempo, ...
        if tok.kind == "NAME":
            name_tok = self._advance()
            return resolve_named_zone(name_tok.value)

        raise ParseError(
            f"Expected pace or zone after '@', got '{tok.value}'", pos=tok.pos
        )

    def _parse_pace_target(self) -> PaceTarget:
        """Parse ``M:SS`` or ``M:SS-M:SS``."""
        pace1_tok = self._expect("PACE")
        speed1 = pace_str_to_speed(pace1_tok.value)

        if self._match("DASH"):
            pace2_tok = self._expect("PACE")
            speed2 = pace_str_to_speed(pace2_tok.value)
            # Slower pace = lower speed, faster pace = higher speed
            low = min(speed1, speed2)
            high = max(speed1, speed2)
            return PaceTarget(low=low, high=high)

        # Single pace — create a small range around it (+/- 5 sec/km)
        # e.g. 5:00/km -> range 4:55 to 5:05
        total_sec = 1000.0 / speed1
        low_speed = 1000.0 / (total_sec + 5)
        high_speed = 1000.0 / max(total_sec - 5, 1)
        return PaceTarget(low=low_speed, high=high_speed)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def parse(text: str, name: str | None = None) -> Workout:
    """Parse a workout DSL string into a :class:`Workout`.

    Args:
        text: The workout description, e.g. ``"10min@Z2,3x8min@threshold"``.
        name: Optional workout name. Defaults to the raw input text.

    Returns:
        A :class:`Workout` instance.

    Raises:
        ParseError: If the input cannot be parsed.
    """
    text = text.strip()
    if not text:
        raise ParseError("Empty workout string")

    tokens = _tokenize(text)
    if not tokens:
        raise ParseError("No valid tokens found in input")

    parser = _Parser(tokens, text)
    steps = parser.parse_workout()

    workout_name = name if name else text
    return Workout(name=workout_name, steps=steps)
