#!/usr/bin/env python3
"""Headless Linear client for Studio autonomy."""

from __future__ import annotations

import json
import os
import urllib.error
import urllib.request
from collections.abc import Callable, Iterable
from typing import Any


LINEAR_API_URL = "https://api.linear.app/graphql"
AUTONOMY_OK = "autonomy-ok"
AUTONOMY_CLAIMED = "autonomy-claimed"
AUTONOMY_BLOCKED = "autonomy-blocked"
READY_STATES = {"Backlog", "Todo"}
BLOCKING_LABELS = {AUTONOMY_CLAIMED, AUTONOMY_BLOCKED}

Transport = Callable[[str, dict[str, str], dict[str, Any]], Any]


def _default_transport(url: str, headers: dict[str, str], payload: dict[str, Any]) -> Any:
    body = json.dumps(payload).encode("utf-8")
    request = urllib.request.Request(url, data=body, headers=headers, method="POST")
    with urllib.request.urlopen(request, timeout=20) as response:
        return json.loads(response.read().decode("utf-8"))


def _coerce_response(raw: Any) -> dict[str, Any]:
    if isinstance(raw, dict):
        return raw
    if isinstance(raw, tuple) and len(raw) == 2:
        _status, raw = raw
    if hasattr(raw, "json"):
        data = raw.json()
        if isinstance(data, dict):
            return data
    if isinstance(raw, bytes):
        raw = raw.decode("utf-8")
    if isinstance(raw, str):
        data = json.loads(raw)
        if isinstance(data, dict):
            return data
    raise RuntimeError("Linear GraphQL response was not a JSON object")


def _gql(
    query: str,
    vars: dict[str, Any] | None = None,
    *,
    transport: Transport | None = None,
) -> dict[str, Any]:
    """POST a GraphQL operation to Linear and return its decoded JSON payload."""
    token = os.environ.get("LINEAR_API_KEY")
    if not token:
        raise RuntimeError("LINEAR_API_KEY is required")

    headers = {
        "Authorization": token,
        "Content-Type": "application/json",
    }
    payload = {"query": query, "variables": vars or {}}
    raw = (transport or _default_transport)(LINEAR_API_URL, headers, payload)
    data = _coerce_response(raw)
    if data.get("errors"):
        raise RuntimeError(f"Linear GraphQL errors: {data['errors']}")
    return data


def _nodes(value: Any) -> list[Any]:
    if isinstance(value, dict):
        if isinstance(value.get("nodes"), list):
            return value.get("nodes") or []
        if isinstance(value.get("edges"), list):
            return [
                edge.get("node") if isinstance(edge, dict) else edge
                for edge in value.get("edges") or []
            ]
        return []
    if isinstance(value, list):
        return value
    return []


def _label_name(label: Any) -> str:
    if isinstance(label, str):
        return label
    if isinstance(label, dict):
        return str(label.get("name") or label.get("label") or "")
    return str(label or "")


def _labels(issue: dict[str, Any]) -> list[str]:
    names: list[str] = []
    for label in _nodes(issue.get("labels")):
        name = _label_name(label).strip()
        if name:
            names.append(name)
    return names


def _state_name(issue: dict[str, Any]) -> str:
    state = issue.get("state")
    if isinstance(state, dict):
        return str(state.get("name") or "")
    return str(state or "")


def _contains_open_pr_link(value: Any) -> bool:
    if value is None:
        return False
    if isinstance(value, str):
        text = value.lower()
        return "/pull/" in text or "pull request" in text
    if isinstance(value, dict):
        state = str(value.get("state") or value.get("status") or "").lower()
        if state in {"closed", "merged", "done"}:
            return False
        return any(_contains_open_pr_link(item) for item in value.values())
    if isinstance(value, Iterable) and not isinstance(value, (bytes, str)):
        return any(_contains_open_pr_link(item) for item in value)
    return False


def _has_open_pr(issue: dict[str, Any]) -> bool:
    for key in ("has_open_pr", "hasOpenPr", "hasOpenPR", "open_pr", "openPullRequest"):
        if issue.get(key):
            return True

    for key in ("pullRequests", "pull_requests", "prs", "attachments", "links"):
        if _contains_open_pr_link(issue.get(key)):
            return True
    return False


def _normalize_issue(issue: dict[str, Any]) -> dict[str, Any]:
    labels = _labels(issue)
    has_open_pr = _has_open_pr(issue)
    return {
        "issue_id": issue.get("id"),
        "identifier": issue.get("identifier"),
        "title": issue.get("title") or "",
        "body": issue.get("description") or issue.get("body") or "",
        "labels": labels,
        "url": issue.get("url") or "",
        "has_open_pr": has_open_pr,
    }


def _resolve_team_id(
    team: str,
    *,
    transport: Transport | None = None,
) -> str:
    query = """
query LinearTeamByName($team: String!) {
  teams(filter: { name: { eq: $team } }) {
    nodes { id name }
  }
}
"""
    data = _gql(query, {"team": team}, transport=transport)
    teams = _nodes(((data.get("data") or {}).get("teams") or {}))
    for node in teams:
        if isinstance(node, dict) and node.get("id"):
            return str(node["id"])
    raise RuntimeError(f"Linear team not found: {team}")


def create_issue(
    title: str,
    description: str,
    *,
    team: str = "Recoil",
    label_ids: list[str] | None = None,
    transport: Transport | None = None,
) -> dict[str, Any]:
    """Create a Linear issue and return the normalized issueCreate result."""
    team_id = _resolve_team_id(team, transport=transport)
    mutation = """
mutation LinearCreateIssue(
  $teamId: String!
  $title: String!
  $description: String!
  $labelIds: [String!]
) {
  issueCreate(
    input: {
      teamId: $teamId
      title: $title
      description: $description
      labelIds: $labelIds
    }
  ) {
    success
    issue { id identifier url }
  }
}
"""
    data = _gql(
        mutation,
        {
            "teamId": team_id,
            "title": title,
            "description": description,
            "labelIds": label_ids or [],
        },
        transport=transport,
    )
    result = ((data.get("data") or {}).get("issueCreate") or {})
    success = bool(result.get("success"))
    issue = result.get("issue")
    if not success or not isinstance(issue, dict):
        raise RuntimeError("Linear issueCreate failed")
    return {
        "success": success,
        "id": issue.get("id"),
        "identifier": issue.get("identifier"),
        "url": issue.get("url"),
    }


def ensure_label(
    name: str = "auto-filed",
    *,
    team: str = "Recoil",
    transport: Transport | None = None,
) -> str:
    """Return an existing team label id, creating the label if needed."""
    team_id = _resolve_team_id(team, transport=transport)
    query = """
query LinearIssueLabels($teamId: ID!) {
  issueLabels(first: 250, filter: { team: { id: { eq: $teamId } } }) {
    nodes { id name }
  }
}
"""
    data = _gql(query, {"teamId": team_id}, transport=transport)
    labels = _nodes(((data.get("data") or {}).get("issueLabels") or {}))
    for label in labels:
        if (
            isinstance(label, dict)
            and label.get("name") == name
            and label.get("id")
        ):
            return str(label["id"])

    mutation = """
mutation LinearCreateIssueLabel($name: String!, $teamId: String!) {
  issueLabelCreate(input: { name: $name, teamId: $teamId }) {
    success
    issueLabel { id name }
  }
}
"""
    data = _gql(
        mutation,
        {"name": name, "teamId": team_id},
        transport=transport,
    )
    result = ((data.get("data") or {}).get("issueLabelCreate") or {})
    label = result.get("issueLabel")
    if not result.get("success") or not isinstance(label, dict) or not label.get("id"):
        raise RuntimeError("Linear issueLabelCreate failed")
    return str(label["id"])


def find_open_issue_by_finding_key(
    finding_key: str,
    *,
    team: str = "Recoil",
    transport: Transport | None = None,
) -> dict[str, Any] | None:
    """Find the first non-terminal issue containing the finding_key token."""
    query = """
query LinearFindingKey($q: String!, $team: String!) {
  issues(
    filter: {
      searchableContent: { contains: $q }
      team: { name: { eq: $team } }
    }
  ) {
    nodes {
      id
      identifier
      url
      state { type }
    }
  }
}
"""
    data = _gql(
        query,
        {"q": f"finding_key:{finding_key}", "team": team},
        transport=transport,
    )
    issues = _nodes(((data.get("data") or {}).get("issues") or {}))
    for issue in issues:
        if not isinstance(issue, dict):
            continue
        state = issue.get("state") if isinstance(issue.get("state"), dict) else {}
        state_type = str((state or {}).get("type") or "").lower()
        if state_type in {"completed", "canceled"}:
            continue
        return {
            "identifier": issue.get("identifier"),
            "id": issue.get("id"),
            "url": issue.get("url"),
        }
    return None


def _is_candidate(issue: dict[str, Any]) -> bool:
    labels = {label.lower() for label in _labels(issue)}
    return (
        AUTONOMY_OK in labels
        and not labels.intersection(BLOCKING_LABELS)
        and _state_name(issue) in READY_STATES
        and not _has_open_pr(issue)
    )


def list_candidates(
    team: str | None = None,
    *,
    transport: Transport | None = None,
) -> list[dict[str, Any]]:
    """Return normalized Linear issues ready for the local readiness gate."""
    if team:
        query = """
query AutonomyCandidates($team: String) {
  issues(
    first: 100
    filter: {
      labels: { name: { eq: "autonomy-ok" } }
      state: { name: { in: ["Backlog", "Todo"] } }
      team: { name: { eq: $team } }
    }
  ) {
    nodes {
      id
      identifier
      title
      description
      url
      state { name }
      labels { nodes { id name } }
      attachments { nodes { url title subtitle } }
    }
  }
}
"""
        variables = {"team": team}
    else:
        query = """
query AutonomyCandidates {
  issues(
    first: 100
    filter: {
      labels: { name: { eq: "autonomy-ok" } }
      state: { name: { in: ["Backlog", "Todo"] } }
    }
  ) {
    nodes {
      id
      identifier
      title
      description
      url
      state { name }
      labels { nodes { id name } }
      attachments { nodes { url title subtitle } }
    }
  }
}
"""
        variables = {}
    data = _gql(query, variables, transport=transport)
    issues = _nodes(((data.get("data") or {}).get("issues") or {}))
    return [_normalize_issue(issue) for issue in issues if isinstance(issue, dict) and _is_candidate(issue)]


def get_issue(
    issue_id: str,
    *,
    transport: Transport | None = None,
) -> dict[str, Any] | None:
    """Return one normalized issue by id without candidate/readiness filtering."""
    query = """
query AutonomyIssue($issueId: String!) {
  issue(id: $issueId) {
    id
    identifier
    title
    description
    url
    state { name }
    labels { nodes { id name } }
    attachments { nodes { url title subtitle } }
  }
}
"""
    data = _gql(query, {"issueId": issue_id}, transport=transport)
    issue = ((data.get("data") or {}).get("issue") or None)
    if not isinstance(issue, dict):
        return None
    return _normalize_issue(issue)


def _labels_for_issue(
    issue_id: str,
    *,
    add: set[str],
    remove: set[str] | None = None,
    transport: Transport | None = None,
) -> list[str]:
    query = """
query AutonomyIssueLabels($issueId: String!) {
  issue(id: $issueId) {
    id
    labels { nodes { id name } }
  }
  issueLabels(first: 250) {
    nodes { id name }
  }
}
"""
    data = _gql(query, {"issueId": issue_id}, transport=transport)
    graph = data.get("data") or {}
    issue = graph.get("issue") or {}
    current = [label for label in _nodes(issue.get("labels")) if isinstance(label, dict)]
    available = [label for label in _nodes(graph.get("issueLabels")) if isinstance(label, dict)]
    by_name = {
        str(label.get("name") or ""): str(label.get("id") or "")
        for label in [*available, *current]
        if label.get("name") and label.get("id")
    }

    label_ids: list[str] = []
    remove = remove or set()
    for label in current:
        name = str(label.get("name") or "")
        label_id = str(label.get("id") or "")
        if label_id and name not in remove and label_id not in label_ids:
            label_ids.append(label_id)

    for name in sorted(add):
        label_id = by_name.get(name)
        if not label_id:
            raise RuntimeError(f"missing Linear label: {name}")
        if label_id not in label_ids:
            label_ids.append(label_id)
    return label_ids


def _update_issue_labels(
    issue_id: str,
    label_ids: list[str],
    *,
    transport: Transport | None = None,
) -> bool:
    mutation = """
mutation AutonomyUpdateLabels($issueId: String!, $labelIds: [String!]) {
  issueUpdate(id: $issueId, input: { labelIds: $labelIds }) {
    success
  }
}
"""
    data = _gql(
        mutation,
        {"issueId": issue_id, "labelIds": label_ids},
        transport=transport,
    )
    return bool(((data.get("data") or {}).get("issueUpdate") or {}).get("success"))


def _comment(
    issue_id: str,
    body: str,
    *,
    transport: Transport | None = None,
) -> bool:
    mutation = """
mutation AutonomyComment($issueId: String!, $body: String!) {
  commentCreate(input: { issueId: $issueId, body: $body }) {
    success
  }
}
"""
    data = _gql(mutation, {"issueId": issue_id, "body": body}, transport=transport)
    return bool(((data.get("data") or {}).get("commentCreate") or {}).get("success"))


def project_claim(
    issue_id: str,
    identifier: str,
    run_id: str,
    *,
    transport: Transport | None = None,
) -> bool:
    """Best-effort projection of a local claim into Linear labels/comments."""
    del identifier
    try:
        label_ids = _labels_for_issue(
            issue_id,
            add={AUTONOMY_CLAIMED},
            transport=transport,
        )
        if not _comment(
            issue_id,
            f"claimed by autonomy run {run_id}",
            transport=transport,
        ):
            return False
        return _update_issue_labels(issue_id, label_ids, transport=transport)
    except Exception:
        return False


def project_status(
    issue_id: str,
    text: str,
    *,
    transport: Transport | None = None,
) -> bool:
    """Best-effort terminal status comment."""
    try:
        return _comment(issue_id, text, transport=transport)
    except Exception:
        return False


def mark_blocked(
    issue_id: str,
    *,
    transport: Transport | None = None,
) -> bool:
    """Best-effort swap from autonomy-ok to autonomy-blocked."""
    try:
        label_ids = _labels_for_issue(
            issue_id,
            add={AUTONOMY_BLOCKED},
            remove={AUTONOMY_OK},
            transport=transport,
        )
        return _update_issue_labels(issue_id, label_ids, transport=transport)
    except Exception:
        return False
