"""Jira client interfaces and implementations."""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol

import httpx

from packages.config import Settings, get_settings
from packages.integrations.sanitize import sanitize_text


@dataclass
class JiraIssue:
    key: str
    summary: str
    description: str
    status: str
    issue_type: str
    labels: List[str] = field(default_factory=list)
    acceptance_criteria: List[str] = field(default_factory=list)
    comments: List[Dict[str, Any]] = field(default_factory=list)
    attachments: List[Dict[str, Any]] = field(default_factory=list)
    project_key: str = ""
    assignee_account_id: str = ""
    assignee_display_name: str = ""


class JiraClient(Protocol):
    async def get_issue(self, key: str) -> JiraIssue: ...

    async def add_comment(self, key: str, body: str) -> None: ...

    async def transition_issue(self, key: str, target_status_name: str) -> bool: ...

    async def search_issues(self, jql: str, *, max_results: int = 20) -> List[JiraIssue]: ...


class MockJiraClient:
    """In-memory Jira used for local/dev and tests."""

    def __init__(self) -> None:
        self.issues: Dict[str, JiraIssue] = {}
        self.comments_posted: List[Dict[str, str]] = []
        self.transitions_posted: List[Dict[str, str]] = []
        self.fail_transitions: bool = False
        self.block_transitions: bool = False
        self.last_search_jql: str = ""

    def seed(self, issue: JiraIssue) -> None:
        self.issues[issue.key] = issue

    async def get_issue(self, key: str) -> JiraIssue:
        if key not in self.issues:
            raise KeyError(f"Issue not found: {key}")
        return self.issues[key]

    async def add_comment(self, key: str, body: str) -> None:
        if key not in self.issues:
            raise KeyError(f"Issue not found: {key}")
        issue = self.issues[key]
        final_body = prepend_assignee_mention_text(
            body,
            account_id=issue.assignee_account_id,
            display_name=issue.assignee_display_name,
        )
        self.comments_posted.append(
            {
                "key": key,
                "body": final_body,
                "mentioned_account_id": issue.assignee_account_id or "",
            }
        )
        self.issues[key].comments.append(
            {"author": "agent-nova", "body": final_body, "account_id": "bot"}
        )

    async def transition_issue(self, key: str, target_status_name: str) -> bool:
        if key not in self.issues:
            raise KeyError(f"Issue not found: {key}")
        if self.fail_transitions:
            raise RuntimeError("mock jira transition failure")
        issue = self.issues[key]
        if issue.status.lower() == target_status_name.lower():
            return True
        if self.block_transitions:
            return False
        issue.status = target_status_name
        self.transitions_posted.append({"key": key, "status": target_status_name})
        return True

    async def search_issues(self, jql: str, *, max_results: int = 20) -> List[JiraIssue]:
        """Minimal JQL support for tests: project = X AND key != Y."""
        self.last_search_jql = jql
        project_m = re.search(r'project\s*=\s*"?([A-Z][A-Z0-9]+)"?', jql, re.I)
        exclude_m = re.search(r"key\s*!=\s*([A-Z][A-Z0-9]+-\d+)", jql, re.I)
        project = (project_m.group(1) if project_m else "").upper()
        exclude = (exclude_m.group(1) if exclude_m else "").upper()
        results: List[JiraIssue] = []
        for issue in self.issues.values():
            if exclude and issue.key.upper() == exclude:
                continue
            pk = (issue.project_key or issue.key.split("-")[0]).upper()
            if project and pk != project:
                continue
            results.append(issue)
            if len(results) >= max_results:
                break
        return results


class HttpJiraClient:
    def __init__(self, settings: Optional[Settings] = None) -> None:
        self.settings = settings or get_settings()
        self._auth = (self.settings.jira_email, self.settings.jira_api_token)

    def _url(self, path: str) -> str:
        return f"{self.settings.jira_base_url.rstrip('/')}/rest/api/3{path}"

    async def get_issue(self, key: str) -> JiraIssue:
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.get(
                self._url(f"/issue/{key}"),
                auth=self._auth,
                params={
                    "fields": "summary,description,status,issuetype,labels,attachment,comment,project,assignee"
                },
            )
            resp.raise_for_status()
            data = resp.json()
        fields = data.get("fields", {})
        description = _adf_to_text(fields.get("description"))
        comments = []
        for c in fields.get("comment", {}).get("comments", []):
            comments.append(
                {
                    "author": c.get("author", {}).get("displayName"),
                    "account_id": c.get("author", {}).get("accountId"),
                    "body": sanitize_text(_adf_to_text(c.get("body"))),
                }
            )
        attachments = [
            {"filename": a.get("filename"), "id": a.get("id")}
            for a in fields.get("attachment", []) or []
        ]
        assignee = fields.get("assignee") or {}
        return JiraIssue(
            key=data["key"],
            summary=sanitize_text(fields.get("summary") or ""),
            description=sanitize_text(description),
            status=(fields.get("status") or {}).get("name") or "",
            issue_type=(fields.get("issuetype") or {}).get("name") or "",
            labels=list(fields.get("labels") or []),
            comments=comments,
            attachments=attachments,
            project_key=(fields.get("project") or {}).get("key") or "",
            acceptance_criteria=_extract_ac(description),
            assignee_account_id=assignee.get("accountId") or "",
            assignee_display_name=sanitize_text(assignee.get("displayName") or ""),
        )

    async def add_comment(self, key: str, body: str) -> None:
        account_id, display_name = await self._get_assignee(key)
        payload = {
            "body": comment_adf_with_assignee_mention(
                body,
                account_id=account_id,
                display_name=display_name,
            )
        }
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.post(
                self._url(f"/issue/{key}/comment"),
                auth=self._auth,
                json=payload,
            )
            resp.raise_for_status()

    async def _get_assignee(self, key: str) -> tuple[str, str]:
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.get(
                self._url(f"/issue/{key}"),
                auth=self._auth,
                params={"fields": "assignee"},
            )
            resp.raise_for_status()
            assignee = (resp.json().get("fields") or {}).get("assignee") or {}
        return assignee.get("accountId") or "", sanitize_text(assignee.get("displayName") or "")

    async def transition_issue(self, key: str, target_status_name: str) -> bool:
        """Move issue to target status by name. Returns True if already there or transitioned."""
        target = (target_status_name or "").strip()
        if not target:
            return False

        async with httpx.AsyncClient(timeout=30.0) as client:
            issue_resp = await client.get(
                self._url(f"/issue/{key}"),
                auth=self._auth,
                params={"fields": "status"},
            )
            issue_resp.raise_for_status()
            current = (
                ((issue_resp.json().get("fields") or {}).get("status") or {}).get("name") or ""
            )
            if current.lower() == target.lower():
                return True

            trans_resp = await client.get(
                self._url(f"/issue/{key}/transitions"),
                auth=self._auth,
            )
            trans_resp.raise_for_status()
            transitions = (trans_resp.json() or {}).get("transitions") or []
            match = next(
                (
                    t
                    for t in transitions
                    if ((t.get("to") or {}).get("name") or "").lower() == target.lower()
                ),
                None,
            )
            if match is None:
                return False

            post_resp = await client.post(
                self._url(f"/issue/{key}/transitions"),
                auth=self._auth,
                json={"transition": {"id": str(match["id"])}},
            )
            post_resp.raise_for_status()
            return True

    async def search_issues(self, jql: str, *, max_results: int = 20) -> List[JiraIssue]:
        # Jira Cloud removed GET/POST /rest/api/3/search (410 Gone).
        # Use enhanced search: POST /rest/api/3/search/jql
        fields = [
            "summary",
            "description",
            "status",
            "issuetype",
            "labels",
            "project",
            "assignee",
        ]
        async with httpx.AsyncClient(timeout=30.0) as client:
            resp = await client.post(
                self._url("/search/jql"),
                auth=self._auth,
                json={
                    "jql": jql,
                    "maxResults": max_results,
                    "fields": fields,
                },
            )
            resp.raise_for_status()
            data = resp.json()
        results: List[JiraIssue] = []
        for item in data.get("issues") or []:
            issue_fields = item.get("fields") or {}
            description = _adf_to_text(issue_fields.get("description"))
            assignee = issue_fields.get("assignee") or {}
            results.append(
                JiraIssue(
                    key=item.get("key") or "",
                    summary=sanitize_text(issue_fields.get("summary") or ""),
                    description=sanitize_text(description),
                    status=(issue_fields.get("status") or {}).get("name") or "",
                    issue_type=(issue_fields.get("issuetype") or {}).get("name") or "",
                    labels=list(issue_fields.get("labels") or []),
                    project_key=(issue_fields.get("project") or {}).get("key") or "",
                    acceptance_criteria=_extract_ac(description),
                    assignee_account_id=assignee.get("accountId") or "",
                    assignee_display_name=sanitize_text(assignee.get("displayName") or ""),
                )
            )
        return results


_URL_RE = re.compile(r"https?://[^\s<>\[\]()]+")
_ORDERED_RE = re.compile(r"^(\d+)\.\s+(.*)$")
_BULLET_RE = re.compile(r"^[-•]\s+(.*)$")
_HEADING_RE = re.compile(r"^##\s+(.*)$")
_LABEL_RE = re.compile(r"^([A-Za-z][A-Za-z0-9 /&_-]{0,40}):\s*(.*)$")


def prepend_assignee_mention_text(
    body: str,
    *,
    account_id: str = "",
    display_name: str = "",
) -> str:
    """Plain-text equivalent of assignee mention (used by mock / logs)."""
    if not (account_id or "").strip():
        return body
    label = (display_name or account_id).strip() or account_id
    return f"@{label}\n\n{body}"


def comment_adf_with_assignee_mention(
    body: str,
    *,
    account_id: str = "",
    display_name: str = "",
) -> Dict[str, Any]:
    """Build comment ADF, optionally starting with a real Jira @mention."""
    doc = text_to_adf(body)
    if not (account_id or "").strip():
        return doc
    label = (display_name or account_id).strip() or account_id
    mention_para: Dict[str, Any] = {
        "type": "paragraph",
        "content": [
            {
                "type": "mention",
                "attrs": {
                    "id": account_id,
                    "text": f"@{label}",
                    "accessLevel": "",
                },
            }
        ],
    }
    content = list(doc.get("content") or [])
    doc["content"] = [mention_para, *content]
    return doc


def text_to_adf(body: str) -> Dict[str, Any]:
    """Convert Agent Nova plain-text comments into readable Jira ADF.

    Supports:
    - ``## Heading`` → heading level 3
    - blank lines → paragraph breaks
    - ``1. item`` / ``- item`` → lists
    - ``Label: value`` → bold label
    - bare URLs → clickable links
    """
    lines = (body or "").replace("\r\n", "\n").split("\n")
    content: List[Dict[str, Any]] = []
    i = 0
    while i < len(lines):
        line = lines[i]
        if not line.strip():
            i += 1
            continue

        heading = _HEADING_RE.match(line.strip())
        if heading:
            content.append(
                {
                    "type": "heading",
                    "attrs": {"level": 3},
                    "content": _inline_nodes(heading.group(1).strip()),
                }
            )
            i += 1
            continue

        ordered_items: List[str] = []
        while i < len(lines):
            m = _ORDERED_RE.match(lines[i].strip())
            if not m:
                break
            ordered_items.append(m.group(2))
            i += 1
        if ordered_items:
            content.append(
                {
                    "type": "orderedList",
                    "attrs": {"order": 1},
                    "content": [_list_item(text) for text in ordered_items],
                }
            )
            continue

        bullet_items: List[str] = []
        while i < len(lines):
            m = _BULLET_RE.match(lines[i].strip())
            if not m:
                break
            bullet_items.append(m.group(1))
            i += 1
        if bullet_items:
            content.append(
                {
                    "type": "bulletList",
                    "content": [_list_item(text) for text in bullet_items],
                }
            )
            continue

        content.append({"type": "paragraph", "content": _paragraph_inlines(line)})
        i += 1

    if not content:
        content.append({"type": "paragraph", "content": [{"type": "text", "text": " "}]})
    return {"type": "doc", "version": 1, "content": content}


def _list_item(text: str) -> Dict[str, Any]:
    return {
        "type": "listItem",
        "content": [{"type": "paragraph", "content": _paragraph_inlines(text)}],
    }


def _paragraph_inlines(line: str) -> List[Dict[str, Any]]:
    stripped = line.strip()
    # Do not treat URL schemes (https:) as bold labels
    if not _URL_RE.match(stripped):
        label = _LABEL_RE.match(stripped)
        if label and label.group(2) != "":
            nodes: List[Dict[str, Any]] = [
                {
                    "type": "text",
                    "text": f"{label.group(1)}: ",
                    "marks": [{"type": "strong"}],
                }
            ]
            nodes.extend(_inline_nodes(label.group(2)))
            return nodes or [{"type": "text", "text": " "}]
        if label and label.group(2) == "":
            return [
                {
                    "type": "text",
                    "text": f"{label.group(1)}:",
                    "marks": [{"type": "strong"}],
                }
            ]
    return _inline_nodes(stripped) or [{"type": "text", "text": " "}]


def _inline_nodes(text: str) -> List[Dict[str, Any]]:
    nodes: List[Dict[str, Any]] = []
    pos = 0
    for match in _URL_RE.finditer(text):
        if match.start() > pos:
            nodes.append({"type": "text", "text": text[pos : match.start()]})
        raw = match.group(0)
        url = raw.rstrip(".,);]")
        trailing = raw[len(url) :]
        nodes.append(
            {
                "type": "text",
                "text": url,
                "marks": [{"type": "link", "attrs": {"href": url}}],
            }
        )
        if trailing:
            nodes.append({"type": "text", "text": trailing})
        pos = match.end()
    if pos < len(text):
        nodes.append({"type": "text", "text": text[pos:]})
    return nodes


def _adf_to_text(node: Any) -> str:
    if node is None:
        return ""
    if isinstance(node, str):
        return node
    if isinstance(node, dict):
        if node.get("type") == "text":
            return node.get("text") or ""
        if node.get("type") == "mention":
            attrs = node.get("attrs") or {}
            return attrs.get("text") or f"@{attrs.get('id') or ''}"
        parts = [_adf_to_text(c) for c in node.get("content", [])]
        sep = "\n" if node.get("type") in {"paragraph", "heading", "listItem"} else ""
        return sep.join(p for p in parts if p)
    if isinstance(node, list):
        return "\n".join(_adf_to_text(n) for n in node)
    return ""


def _extract_ac(description: str) -> List[str]:
    lines = []
    capture = False
    for line in description.splitlines():
        lower = line.lower().strip()
        if "acceptance criteria" in lower:
            capture = True
            continue
        if capture:
            if not line.strip():
                if lines:
                    break
                continue
            cleaned = line.strip().lstrip("-*•0123456789. ").strip()
            if cleaned:
                lines.append(cleaned)
    if lines:
        return lines
    return _extract_ac_fallback(description)


def _extract_ac_fallback(description: str) -> List[str]:
    """Infer AC from substantive numbered/bulleted lines when no AC header exists."""
    candidates: List[str] = []
    for line in description.splitlines():
        stripped = line.strip()
        if not stripped:
            continue
        lower = stripped.lower()
        if lower.startswith("acceptance criteria"):
            continue
        cleaned = stripped.lstrip("-*•0123456789. ").strip()
        if len(cleaned) <= 20:
            continue
        if any(
            kw in lower
            for kw in (
                "picklist",
                "validation",
                "permission set",
                "field",
                "must",
                "should",
                "blocks",
                "requires",
                "grants",
                "exists on",
            )
        ):
            candidates.append(cleaned)
    return candidates


_mock_singleton: Optional[MockJiraClient] = None


def get_jira_client(settings: Optional[Settings] = None) -> JiraClient:
    global _mock_singleton
    settings = settings or get_settings()
    if settings.jira_provider == "mock":
        if _mock_singleton is None:
            _mock_singleton = MockJiraClient()
        return _mock_singleton
    return HttpJiraClient(settings)


def reset_mock_jira() -> MockJiraClient:
    global _mock_singleton
    _mock_singleton = MockJiraClient()
    return _mock_singleton
