"""Sprint and workload operations."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from zenhub_client.exceptions import ZenhubError, ZenhubGraphQLError
from zenhub_client.graphql import GraphQLClient
from zenhub_client.models import SprintInfo, SprintWorkloadSummary
from zenhub_client.models import SprintIssue, SprintInfo, SprintWorkloadSummary
from zenhub_client.queries import (
    SPRINT_ISSUES_DETAILS_PAGED,
    SPRINT_ISSUES_DETAILS_UNPAGED,
    SPRINT_ISSUES_PAGED,
    SPRINT_ISSUES_UNPAGED,
)
from zenhub_client.utils import get_in


def _safe_enum_token(value: str) -> str:
    """Validate a string to be embedded as a GraphQL enum token.

    Zenhub examples often use `eq:OPEN` rather than `eq:"OPEN"`.
    To avoid injection, we restrict to A-Z, 0-9 and underscore.
    """
    token = str(value).strip().upper()
    if not token:
        raise ZenhubError("state_eq cannot be empty")
    allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"
    if any(ch not in allowed for ch in token):
        raise ZenhubError(f"Invalid enum token: {value!r}")
    return token


@dataclass(slots=True)
class SprintService:
    """Operations for listing sprints and computing sprint workload summary."""

    _graphql: GraphQLClient

    def list_sprints(
        self,
        workspace_id: str,
        *,
        last: int | None = None,
        state_eq: str | None = None,
    ) -> list[SprintInfo]:
        """List sprints in a workspace.

        The Zenhub GraphQL schema for `workspace.sprints(...)` may vary.
        This method builds a query that mirrors common community examples.
        """
        args: list[str] = []
        if last is not None:
            args.append(f"last: {int(last)}")
        if state_eq:
            state_token = _safe_enum_token(state_eq)
            args.append(f"filters:{{state:{{eq:{state_token}}}}}")

        args_str = f"({', '.join(args)})" if args else ""

        query = f"""
        query ListSprints($workspaceId: ID!) {{
          workspace(id: $workspaceId) {{
            sprints{args_str} {{
              nodes {{
                id
                name
                state
                startAt
                endAt
              }}
            }}
          }}
        }}
        """
        variables = {"workspaceId": str(workspace_id)}
        data = self._graphql.execute(query, variables)
        nodes = get_in(data, ["workspace", "sprints", "nodes"])
        if not isinstance(nodes, list):
            raise ZenhubError(f"Unexpected sprints nodes shape: {nodes!r}")
        return [SprintInfo.from_node(n) for n in nodes if isinstance(n, dict)]

    def get_current_open_sprint(self, workspace_id: str) -> SprintInfo:
        sprints = self.list_sprints(workspace_id, last=1, state_eq="OPEN")
        if not sprints:
            raise ZenhubError("No OPEN sprint found in workspace.")
        return sprints[0]

    def get_sprint_workload_points(self, sprint_id: str) -> SprintWorkloadSummary:
        """Sum issue estimates (story points) for all issues in a sprint.

        Tries a paginated connection first; if Zenhub's schema rejects pagination
        args, falls back to an unpaginated query.
        """
        try:
            return self._get_sprint_workload_points_paged(sprint_id)
        except ZenhubGraphQLError as exc:
            if exc.is_schema_error():
                return self._get_sprint_workload_points_unpaged(sprint_id)
            raise

    def get_sprint_issues(self, sprint_id: str, *, workspace_id: str) -> tuple[SprintInfo, list[SprintIssue]]:
        """Fetch sprint issues including assignees/labels/pipeline where available.

        Tries a richer query first, then falls back to the minimal issue fields.
        """
        try:
            return self._get_sprint_issues_details_paged(sprint_id, workspace_id=workspace_id)
        except ZenhubGraphQLError as exc:
            if exc.is_schema_error():
                try:
                    return self._get_sprint_issues_details_unpaged(sprint_id, workspace_id=workspace_id)
                except ZenhubGraphQLError as exc2:
                    if exc2.is_schema_error():
                        return self._get_sprint_issues_basic_paged(sprint_id)
                    raise
            raise

    def _get_sprint_issues_details_paged(
        self, sprint_id: str, *, workspace_id: str
    ) -> tuple[SprintInfo, list[SprintIssue]]:
        after: str | None = None
        sprint_meta: SprintInfo | None = None
        issues: list[SprintIssue] = []

        while True:
            variables = {"id": str(sprint_id), "workspaceId": str(workspace_id), "after": after}
            data = self._graphql.execute(SPRINT_ISSUES_DETAILS_PAGED.text, variables)
            sprint = get_in(data, ["node"])
            if not isinstance(sprint, dict):
                raise ZenhubError(f"Unexpected sprint node shape: {sprint!r}")

            if sprint_meta is None:
                sprint_meta = SprintInfo.from_node(sprint)

            conn = sprint.get("sprintIssues")
            if not isinstance(conn, dict):
                raise ZenhubError(f"Unexpected sprintIssues shape: {conn!r}")

            nodes = conn.get("nodes")
            if not isinstance(nodes, list):
                raise ZenhubError(f"Unexpected sprintIssues.nodes shape: {nodes!r}")

            for node in nodes:
                if not isinstance(node, dict):
                    continue
                issue_node = node.get("issue")
                if not isinstance(issue_node, dict):
                    continue
                issues.append(SprintIssue.from_node(issue_node))

            page_info = conn.get("pageInfo")
            if not isinstance(page_info, dict):
                break

            has_next = bool(page_info.get("hasNextPage"))
            end_cursor = page_info.get("endCursor")
            if not has_next or not end_cursor:
                break
            after = str(end_cursor)

        if sprint_meta is None:
            raise ZenhubError("Failed to load sprint metadata.")
        return sprint_meta, issues

    def _get_sprint_issues_details_unpaged(
        self, sprint_id: str, *, workspace_id: str
    ) -> tuple[SprintInfo, list[SprintIssue]]:
        variables = {"id": str(sprint_id), "workspaceId": str(workspace_id)}
        data = self._graphql.execute(SPRINT_ISSUES_DETAILS_UNPAGED.text, variables)
        sprint = get_in(data, ["node"])
        if not isinstance(sprint, dict):
            raise ZenhubError(f"Unexpected sprint node shape: {sprint!r}")

        sprint_meta = SprintInfo.from_node(sprint)

        nodes = get_in(sprint, ["sprintIssues", "nodes"])
        if not isinstance(nodes, list):
            raise ZenhubError(f"Unexpected sprintIssues.nodes shape: {nodes!r}")

        issues: list[SprintIssue] = []
        for node in nodes:
            if not isinstance(node, dict):
                continue
            issue_node = node.get("issue")
            if not isinstance(issue_node, dict):
                continue
            issues.append(SprintIssue.from_node(issue_node))
        return sprint_meta, issues

    def _get_sprint_issues_basic_paged(self, sprint_id: str) -> tuple[SprintInfo, list[SprintIssue]]:
        after: str | None = None
        sprint_meta: SprintInfo | None = None
        issues: list[SprintIssue] = []

        while True:
            variables = {"id": str(sprint_id), "after": after}
            data = self._graphql.execute(SPRINT_ISSUES_PAGED.text, variables)
            sprint = get_in(data, ["node"])
            if not isinstance(sprint, dict):
                raise ZenhubError(f"Unexpected sprint node shape: {sprint!r}")

            if sprint_meta is None:
                sprint_meta = SprintInfo.from_node(sprint)

            conn = sprint.get("sprintIssues")
            if not isinstance(conn, dict):
                raise ZenhubError(f"Unexpected sprintIssues shape: {conn!r}")

            nodes = conn.get("nodes")
            if not isinstance(nodes, list):
                raise ZenhubError(f"Unexpected sprintIssues.nodes shape: {nodes!r}")

            for node in nodes:
                if not isinstance(node, dict):
                    continue
                issue_node = node.get("issue")
                if not isinstance(issue_node, dict):
                    continue
                issues.append(SprintIssue.from_node(issue_node))

            page_info = conn.get("pageInfo")
            if not isinstance(page_info, dict):
                break

            has_next = bool(page_info.get("hasNextPage"))
            end_cursor = page_info.get("endCursor")
            if not has_next or not end_cursor:
                break
            after = str(end_cursor)

        if sprint_meta is None:
            raise ZenhubError("Failed to load sprint metadata.")
        return sprint_meta, issues

    def _get_sprint_workload_points_paged(self, sprint_id: str) -> SprintWorkloadSummary:
        total_points = 0.0
        closed_points = 0.0
        issue_count = 0
        closed_issue_count = 0
        missing_estimate_count = 0

        sprint_meta: SprintInfo | None = None

        after: str | None = None
        while True:
            variables = {"id": str(sprint_id), "after": after}
            data = self._graphql.execute(SPRINT_ISSUES_PAGED.text, variables)
            sprint = get_in(data, ["node"])
            if not isinstance(sprint, dict):
                raise ZenhubError(f"Unexpected sprint node shape: {sprint!r}")

            if sprint_meta is None:
                sprint_meta = SprintInfo.from_node(sprint)

            conn = sprint.get("sprintIssues")
            if not isinstance(conn, dict):
                raise ZenhubError(f"Unexpected sprintIssues shape: {conn!r}")

            nodes = conn.get("nodes")
            if not isinstance(nodes, list):
                raise ZenhubError(f"Unexpected sprintIssues.nodes shape: {nodes!r}")

            (total_points, closed_points, issue_count, closed_issue_count, missing_estimate_count) = (
                self._accumulate_issues(
                    nodes,
                    total_points=total_points,
                    closed_points=closed_points,
                    issue_count=issue_count,
                    closed_issue_count=closed_issue_count,
                    missing_estimate_count=missing_estimate_count,
                )
            )

            page_info = conn.get("pageInfo")
            if not isinstance(page_info, dict):
                break

            has_next = bool(page_info.get("hasNextPage"))
            end_cursor = page_info.get("endCursor")
            if not has_next or not end_cursor:
                break
            after = str(end_cursor)

        if sprint_meta is None:
            raise ZenhubError("Failed to load sprint metadata.")

        return SprintWorkloadSummary(
            sprint=sprint_meta,
            total_points=float(total_points),
            issue_count=int(issue_count),
            missing_estimate_count=int(missing_estimate_count),
            closed_issue_count=int(closed_issue_count),
            closed_points=float(closed_points),
        )

    def _get_sprint_workload_points_unpaged(self, sprint_id: str) -> SprintWorkloadSummary:
        variables = {"id": str(sprint_id)}
        data = self._graphql.execute(SPRINT_ISSUES_UNPAGED.text, variables)
        sprint = get_in(data, ["node"])
        if not isinstance(sprint, dict):
            raise ZenhubError(f"Unexpected sprint node shape: {sprint!r}")

        sprint_meta = SprintInfo.from_node(sprint)

        nodes = get_in(sprint, ["sprintIssues", "nodes"])
        if not isinstance(nodes, list):
            raise ZenhubError(f"Unexpected sprintIssues.nodes shape: {nodes!r}")

        total_points, closed_points, issue_count, closed_issue_count, missing_estimate_count = self._accumulate_issues(
            nodes,
            total_points=0.0,
            closed_points=0.0,
            issue_count=0,
            closed_issue_count=0,
            missing_estimate_count=0,
        )

        return SprintWorkloadSummary(
            sprint=sprint_meta,
            total_points=float(total_points),
            issue_count=int(issue_count),
            missing_estimate_count=int(missing_estimate_count),
            closed_issue_count=int(closed_issue_count),
            closed_points=float(closed_points),
        )

    def _accumulate_issues(
        self,
        nodes: list[Any],
        *,
        total_points: float,
        closed_points: float,
        issue_count: int,
        closed_issue_count: int,
        missing_estimate_count: int,
    ) -> tuple[float, float, int, int, int]:
        for node in nodes:
            if not isinstance(node, dict):
                continue
            issue = node.get("issue")
            if not isinstance(issue, dict):
                continue

            issue_count += 1

            estimate = issue.get("estimate")
            points: float
            if not isinstance(estimate, dict) or estimate.get("value") is None:
                missing_estimate_count += 1
                points = 0.0
            else:
                points = float(estimate.get("value"))

            total_points += points

            if str(issue.get("state", "")).upper() == "CLOSED":
                closed_issue_count += 1
                closed_points += points

        return total_points, closed_points, issue_count, closed_issue_count, missing_estimate_count
