"""Sentry → Gitea incident bridge (#607).

Reads unresolved issues/events from a **self-hosted** Sentry, normalizes them
into #612 observations, and reconciles them into durable Gitea issues.

Hard rules (inherited from #612 and restated here):
* Gitea owns workflow state; Sentry is observability **input only**.
* Raw Sentry incidents are never assignable control-plane ``work_items``.
* Dedupe/link/create is delegated to :mod:`incident_bridge` — this module
  never invents a second linking substrate.
* Tokens, DSNs, and raw headers never appear in returns, bodies, or logs.
* The watchdog defaults to dry-run; ``apply`` is explicit.

Network access is injected as ``http_fn`` so the whole surface is testable
without a live Sentry.
"""

from __future__ import annotations

import dataclasses
import json
import os
import re
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any, Callable, Sequence

import incident_bridge
import sentry_observability

PROVIDER = "sentry"

ENV_BASE_URL = "SENTRY_BASE_URL"
ENV_AUTH_TOKEN = "SENTRY_AUTH_TOKEN"
ENV_ORG = "SENTRY_ORG"
ENV_PROJECT = "SENTRY_PROJECT"
ENV_ENVIRONMENT = "SENTRY_ENVIRONMENT"
ENV_BRIDGE_ENABLED = "MCP_SENTRY_ISSUE_BRIDGE_ENABLED"
ENV_MIN_EVENTS = "MCP_SENTRY_MIN_EVENTS_FOR_ISSUE"
ENV_LOOKBACK = "MCP_SENTRY_LOOKBACK"

DEFAULT_BASE_URL = "https://sentry.prgs.cc"
DEFAULT_LOOKBACK = "24h"
DEFAULT_MIN_EVENTS = 2
DEFAULT_TIMEOUT = 15.0
DEFAULT_PAGE_SIZE = 25
MAX_PAGE_SIZE = 100
DEFAULT_MAX_PAGES = 10

_TRUTHY = frozenset({"1", "true", "yes", "on"})
# Absolute local paths embedded in free text. Mirrors the shape matched by
# sentry_observability's internal path detector; each hit is replaced by the
# coarse category from sentry_observability.sanitize_path.
_ABS_PATH_RE = re.compile(
    r"(?:/private)?/(?:Users|home|tmp|var|opt|Volumes)/[^\s\"']*"
)
_LOOKBACK_RE = re.compile(r"^\d+[mhd]$")
_CURSOR_RE = re.compile(r'cursor="([^"]+)"')
_RESULTS_RE = re.compile(r'results="([^"]+)"')
_REL_RE = re.compile(r'rel="([^"]+)"')

# Error kinds surfaced to callers (stable strings; safe to branch on).
ERROR_NOT_CONFIGURED = "not_configured"
ERROR_MISSING_TOKEN = "missing_token"
ERROR_UNAVAILABLE = "sentry_unavailable"
ERROR_HTTP = "sentry_http_error"
ERROR_INVALID_RESPONSE = "invalid_response"
ERROR_BRIDGE_DISABLED = "bridge_disabled"

# Watchdog per-issue dispositions.
ACTION_RECONCILED = "reconciled"
ACTION_SKIPPED_THRESHOLD = "skipped_below_event_threshold"
ACTION_SKIPPED_STATUS = "skipped_not_unresolved"
ACTION_FAILED = "failed"


class SentryApiError(RuntimeError):
    """Sentry read failure with a stable, redacted classification."""

    def __init__(self, message: str, *, kind: str, status: int | None = None):
        super().__init__(incident_bridge.redact_text(message))
        self.kind = kind
        self.status = status

    def as_dict(self) -> dict[str, Any]:
        return {
            "error_kind": self.kind,
            "status": self.status,
            "message": str(self),
        }


@dataclass(frozen=True)
class SentryBridgeConfig:
    """Resolved bridge configuration. Never carries the auth token."""

    base_url: str
    org: str
    project: str
    environment: str | None = None
    lookback: str = DEFAULT_LOOKBACK
    min_events_for_issue: int = DEFAULT_MIN_EVENTS
    bridge_enabled: bool = False
    timeout: float = DEFAULT_TIMEOUT

    def issues_path(self) -> str:
        return f"/api/0/projects/{self.org}/{self.project}/issues/"

    def issue_events_path(self, issue_id: str) -> str:
        return f"/api/0/issues/{issue_id}/events/"

    def as_dict(self) -> dict[str, Any]:
        """Safe projection. The auth token is never included by construction."""
        return {
            "base_url": self.base_url,
            "org": self.org,
            "project": self.project,
            "environment": self.environment,
            "lookback": self.lookback,
            "min_events_for_issue": self.min_events_for_issue,
            "bridge_enabled": self.bridge_enabled,
            "self_hosted": not self.base_url.rstrip("/").endswith("sentry.io"),
        }


def _env_bool(name: str, env: dict[str, str], default: bool = False) -> bool:
    raw = (env.get(name) or "").strip().lower()
    if not raw:
        return default
    return raw in _TRUTHY


def _env_int(name: str, env: dict[str, str], default: int) -> int:
    raw = (env.get(name) or "").strip()
    if not raw:
        return default
    try:
        value = int(raw)
    except ValueError:
        return default
    return value if value >= 1 else default


def load_bridge_config(env: dict[str, str] | None = None) -> SentryBridgeConfig:
    """Build config from environment. Never reads or returns the token value."""
    source = dict(env if env is not None else os.environ)
    base_url = (source.get(ENV_BASE_URL) or DEFAULT_BASE_URL).strip().rstrip("/")
    lookback = (source.get(ENV_LOOKBACK) or DEFAULT_LOOKBACK).strip()
    if not _LOOKBACK_RE.match(lookback):
        lookback = DEFAULT_LOOKBACK
    environment = (source.get(ENV_ENVIRONMENT) or "").strip() or None
    return SentryBridgeConfig(
        base_url=base_url,
        org=(source.get(ENV_ORG) or "").strip(),
        project=(source.get(ENV_PROJECT) or "").strip(),
        environment=environment,
        lookback=lookback,
        min_events_for_issue=_env_int(ENV_MIN_EVENTS, source, DEFAULT_MIN_EVENTS),
        bridge_enabled=_env_bool(ENV_BRIDGE_ENABLED, source, False),
    )


def config_with_overrides(
    config: SentryBridgeConfig,
    *,
    base_url: str | None = None,
    org: str | None = None,
    project: str | None = None,
    lookback: str | None = None,
    min_events_for_issue: int | None = None,
) -> SentryBridgeConfig:
    """Return *config* with explicit per-call overrides applied."""
    overrides: dict[str, Any] = {}
    if base_url:
        overrides["base_url"] = str(base_url).strip().rstrip("/")
    if org:
        overrides["org"] = str(org).strip()
    if project:
        overrides["project"] = str(project).strip()
    if lookback:
        candidate = str(lookback).strip()
        overrides["lookback"] = candidate if _LOOKBACK_RE.match(candidate) else config.lookback
    if min_events_for_issue is not None:
        overrides["min_events_for_issue"] = max(1, int(min_events_for_issue))
    return dataclasses.replace(config, **overrides) if overrides else config


def resolve_token(env: dict[str, str] | None = None) -> str:
    """Return the Sentry auth token from env only (never logged or returned)."""
    source = env if env is not None else os.environ
    return (source.get(ENV_AUTH_TOKEN) or "").strip()


def assert_configured(config: SentryBridgeConfig, token: str) -> None:
    """Fail closed before any network call."""
    missing = [
        name
        for name, value in (
            (ENV_BASE_URL, config.base_url),
            (ENV_ORG, config.org),
            (ENV_PROJECT, config.project),
        )
        if not value
    ]
    if missing:
        raise SentryApiError(
            "Sentry bridge is not configured; missing " + ", ".join(sorted(missing)),
            kind=ERROR_NOT_CONFIGURED,
        )
    if not token:
        raise SentryApiError(
            f"{ENV_AUTH_TOKEN} is not set; refusing to call Sentry (fail closed)",
            kind=ERROR_MISSING_TOKEN,
        )


# --------------------------------------------------------------------------
# HTTP layer (injectable)
# --------------------------------------------------------------------------

# http_fn(url, headers, timeout) -> (status, body_bytes, response_headers)
HttpFn = Callable[[str, dict[str, str], float], "tuple[int, bytes, dict[str, str]]"]


def _default_http_fn(
    url: str, headers: dict[str, str], timeout: float
) -> tuple[int, bytes, dict[str, str]]:
    request = urllib.request.Request(url, headers=headers, method="GET")
    try:
        with urllib.request.urlopen(request, timeout=timeout) as response:
            return (
                int(response.status),
                response.read(),
                {k.lower(): v for k, v in response.headers.items()},
            )
    except urllib.error.HTTPError as exc:  # status is meaningful
        try:
            body = exc.read()
        except Exception:  # noqa: BLE001 - body is best-effort only
            body = b""
        return (
            int(exc.code),
            body,
            {k.lower(): v for k, v in (exc.headers or {}).items()},
        )
    except urllib.error.URLError as exc:
        raise SentryApiError(
            f"Sentry unreachable: {exc.reason}", kind=ERROR_UNAVAILABLE
        ) from exc
    except TimeoutError as exc:
        raise SentryApiError("Sentry request timed out", kind=ERROR_UNAVAILABLE) from exc


def parse_next_cursor(link_header: str | None) -> str | None:
    """Extract the ``rel="next"`` cursor when more results exist."""
    if not link_header:
        return None
    for part in link_header.split(","):
        rel = _REL_RE.search(part)
        if not rel or rel.group(1) != "next":
            continue
        results = _RESULTS_RE.search(part)
        if results and results.group(1).lower() != "true":
            return None
        cursor = _CURSOR_RE.search(part)
        if cursor:
            return cursor.group(1)
    return None


def _get_json(
    config: SentryBridgeConfig,
    path: str,
    params: dict[str, Any],
    *,
    token: str,
    http_fn: HttpFn | None = None,
) -> tuple[Any, dict[str, str]]:
    caller = http_fn or _default_http_fn
    query = urllib.parse.urlencode(
        {k: v for k, v in params.items() if v not in (None, "")}
    )
    url = f"{config.base_url}{path}"
    if query:
        url = f"{url}?{query}"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "application/json",
        "User-Agent": "gitea-tools-sentry-bridge/1.0",
    }
    status, body, response_headers = caller(url, headers, config.timeout)
    if status in (401, 403):
        raise SentryApiError(
            "Sentry rejected the auth token (unauthorized)",
            kind=ERROR_MISSING_TOKEN,
            status=status,
        )
    if status >= 500:
        raise SentryApiError(
            f"Sentry server error (HTTP {status})",
            kind=ERROR_UNAVAILABLE,
            status=status,
        )
    if status >= 400:
        raise SentryApiError(
            f"Sentry request failed (HTTP {status})", kind=ERROR_HTTP, status=status
        )
    try:
        payload = json.loads(body.decode("utf-8") or "null")
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise SentryApiError(
            f"Sentry returned an unparseable response: {exc}",
            kind=ERROR_INVALID_RESPONSE,
            status=status,
        ) from exc
    return payload, response_headers


# --------------------------------------------------------------------------
# Sanitization
# --------------------------------------------------------------------------


def _clean(value: Any) -> str:
    """Redact secrets, then replace embedded local paths with a category token.

    ``sentry_observability.sanitize_path`` categorizes a string that *is* a
    path; it must never be applied to whole free-text fields (it would collapse
    a title or timestamp to ``"other"``). Here it is applied only to substrings
    that actually match an absolute path.
    """
    text = incident_bridge.redact_text(value)
    if not text:
        return ""
    return _ABS_PATH_RE.sub(
        lambda m: f"[path:{sentry_observability.sanitize_path(m.group(0))}]", text
    )


def sanitize_issue(raw: Any) -> dict[str, Any]:
    """Project one raw Sentry issue into a sanitized, LLM-safe summary."""
    if not isinstance(raw, dict):
        raise SentryApiError(
            "Sentry issue payload is not an object", kind=ERROR_INVALID_RESPONSE
        )
    issue_id = raw.get("id")
    if issue_id is None or str(issue_id).strip() == "":
        raise SentryApiError(
            "Sentry issue payload is missing 'id'", kind=ERROR_INVALID_RESPONSE
        )
    metadata = raw.get("metadata") if isinstance(raw.get("metadata"), dict) else {}
    try:
        count = int(raw.get("count"))
    except (TypeError, ValueError):
        count = None
    permalink = _clean(raw.get("permalink"))
    if "[REDACTED]" in permalink:
        permalink = ""
    user_count = raw.get("userCount")
    return {
        "id": str(issue_id).strip(),
        "short_id": _clean(raw.get("shortId")) or None,
        "title": _clean(raw.get("title"))[:200],
        "culprit": _clean(raw.get("culprit")) or None,
        "level": _clean(raw.get("level")) or None,
        "status": str(raw.get("status") or "unresolved").strip().lower() or "unresolved",
        "count": count,
        "user_count": user_count if isinstance(user_count, int) else None,
        "first_seen": _clean(raw.get("firstSeen")) or None,
        "last_seen": _clean(raw.get("lastSeen")) or None,
        "permalink": permalink or None,
        "metadata_value": _clean(metadata.get("value"))[:500] or None,
        "metadata_type": _clean(metadata.get("type")) or None,
    }


def sanitize_event(raw: Any) -> dict[str, Any]:
    """Project one raw Sentry event into a sanitized summary."""
    if not isinstance(raw, dict):
        raise SentryApiError(
            "Sentry event payload is not an object", kind=ERROR_INVALID_RESPONSE
        )
    tags: dict[str, str] = {}
    raw_tags = raw.get("tags")
    if isinstance(raw_tags, list):
        # Sentry events return tags as [{"key": ..., "value": ...}, ...]
        tags = incident_bridge.sanitize_tags(
            {
                t.get("key"): t.get("value")
                for t in raw_tags
                if isinstance(t, dict) and t.get("key")
            }
        )
    elif isinstance(raw_tags, dict):
        tags = incident_bridge.sanitize_tags(raw_tags)
    return {
        "event_id": _clean(raw.get("eventID") or raw.get("id")) or None,
        "message": _clean(raw.get("message") or raw.get("title"))[:2000] or None,
        "date_created": _clean(raw.get("dateCreated")) or None,
        "platform": _clean(raw.get("platform")) or None,
        "environment": _clean(raw.get("environment")) or None,
        "release": _clean(raw.get("release")) or None,
        "tags": tags,
    }


# --------------------------------------------------------------------------
# Reads
# --------------------------------------------------------------------------


def list_issues(
    config: SentryBridgeConfig,
    *,
    token: str,
    query: str = "is:unresolved",
    limit: int = DEFAULT_PAGE_SIZE,
    max_pages: int = DEFAULT_MAX_PAGES,
    cursor: str | None = None,
    environment: str | None = None,
    http_fn: HttpFn | None = None,
) -> dict[str, Any]:
    """List sanitized unresolved Sentry issues, following ``Link`` pagination."""
    assert_configured(config, token)
    page_size = max(1, min(int(limit or DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE))
    pages_allowed = max(1, int(max_pages or 1))

    issues: list[dict[str, Any]] = []
    next_cursor = cursor
    pages_fetched = 0
    for _ in range(pages_allowed):
        payload, headers = _get_json(
            config,
            config.issues_path(),
            {
                "query": query,
                "statsPeriod": config.lookback,
                "limit": page_size,
                "cursor": next_cursor,
                "environment": environment or config.environment,
            },
            token=token,
            http_fn=http_fn,
        )
        pages_fetched += 1
        if payload is None:
            payload = []
        if not isinstance(payload, list):
            raise SentryApiError(
                "Sentry issue list response was not a JSON array",
                kind=ERROR_INVALID_RESPONSE,
            )
        issues.extend(sanitize_issue(item) for item in payload)
        next_cursor = parse_next_cursor(headers.get("link"))
        if not next_cursor:
            break

    return {
        "success": True,
        "issues": issues,
        "count": len(issues),
        "pages_fetched": pages_fetched,
        "next_cursor": next_cursor,
        "inventory_complete": next_cursor is None,
        "config": config.as_dict(),
        "query": query,
    }


def get_issue_events(
    config: SentryBridgeConfig,
    issue_id: str,
    *,
    token: str,
    limit: int = 10,
    http_fn: HttpFn | None = None,
) -> dict[str, Any]:
    """Fetch sanitized recent events plus the latest event for one issue."""
    assert_configured(config, token)
    if not str(issue_id or "").strip():
        raise SentryApiError("issue_id is required", kind=ERROR_INVALID_RESPONSE)
    issue_key = str(issue_id).strip()

    payload, _ = _get_json(
        config,
        config.issue_events_path(issue_key),
        {"limit": max(1, min(int(limit or 10), MAX_PAGE_SIZE))},
        token=token,
        http_fn=http_fn,
    )
    if payload is None:
        payload = []
    if not isinstance(payload, list):
        raise SentryApiError(
            "Sentry event list response was not a JSON array",
            kind=ERROR_INVALID_RESPONSE,
        )
    events = [sanitize_event(item) for item in payload]
    return {
        "success": True,
        "issue_id": issue_key,
        "events": events,
        "count": len(events),
        "latest_event": events[0] if events else None,
        "config": config.as_dict(),
    }


# --------------------------------------------------------------------------
# Observation mapping + policy
# --------------------------------------------------------------------------


def observation_from_issue(
    issue: dict[str, Any],
    config: SentryBridgeConfig,
    *,
    gitea_org: str | None = None,
    gitea_repo: str | None = None,
    latest_event: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Convert a sanitized Sentry issue into a #612 observation dict."""
    tags = dict(latest_event.get("tags") or {}) if isinstance(latest_event, dict) else {}
    environment = None
    if isinstance(latest_event, dict):
        environment = latest_event.get("environment")
    environment = environment or config.environment

    observation: dict[str, Any] = {
        "provider": PROVIDER,
        "provider_base_url": config.base_url,
        "provider_org": config.org,
        "provider_project": config.project,
        "provider_issue_id": issue.get("id"),
        "provider_short_id": issue.get("short_id"),
        "provider_permalink": issue.get("permalink"),
        "title": issue.get("title"),
        "culprit": issue.get("culprit"),
        "summary": issue.get("metadata_value") or issue.get("title"),
        "level": issue.get("level"),
        "status": issue.get("status") or "unresolved",
        "event_count": issue.get("count"),
        "first_seen": issue.get("first_seen"),
        "last_seen": issue.get("last_seen"),
        "environment": environment,
        "tags": tags,
    }
    if gitea_org:
        observation["gitea_org"] = gitea_org
    if gitea_repo:
        observation["gitea_repo"] = gitea_repo
    return observation


def should_bridge_issue(
    issue: dict[str, Any], config: SentryBridgeConfig
) -> tuple[bool, str]:
    """Policy gate: is this Sentry issue worth a durable Gitea issue?"""
    status = str(issue.get("status") or "").strip().lower()
    if status and status != "unresolved":
        return False, f"status '{status}' is not unresolved"
    count = issue.get("count")
    threshold = int(config.min_events_for_issue or 1)
    if isinstance(count, int) and count < threshold:
        return (
            False,
            f"event count {count} below {ENV_MIN_EVENTS} threshold {threshold}",
        )
    return True, "meets bridge policy"


# --------------------------------------------------------------------------
# Watchdog
# --------------------------------------------------------------------------


def watchdog(
    db: Any,
    config: SentryBridgeConfig,
    *,
    token: str,
    apply: bool = False,
    mappings: Sequence[Any] | None = None,
    gitea_org: str | None = None,
    gitea_repo: str | None = None,
    query: str = "is:unresolved",
    limit: int = DEFAULT_PAGE_SIZE,
    max_pages: int = DEFAULT_MAX_PAGES,
    http_fn: HttpFn | None = None,
    create_issue_fn: Any = None,
    comment_issue_fn: Any = None,
    reconcile_fn: Callable[..., dict[str, Any]] | None = None,
    fetch_events: bool = True,
) -> dict[str, Any]:
    """Scan Sentry and reconcile active incidents into Gitea issues.

    Dry-run by default. ``apply=True`` additionally requires the bridge to be
    explicitly enabled via ``MCP_SENTRY_ISSUE_BRIDGE_ENABLED``.

    ``comment_issue_fn`` carries the sanctioned issue-comment route used for
    AC4 recurrence comments on already-linked issues; dry runs never comment.
    """
    result: dict[str, Any] = {
        "success": False,
        "apply": bool(apply),
        "scanned": 0,
        "reconciled": 0,
        "skipped": 0,
        "failed": 0,
        "results": [],
        "reasons": [],
        "config": config.as_dict(),
        "raw_incident_assignable": False,
        "durable_work_system": "gitea_issues",
    }

    if apply and not config.bridge_enabled:
        result["reasons"].append(
            f"{ENV_BRIDGE_ENABLED} is not enabled; apply refused (fail closed)"
        )
        result["error_kind"] = ERROR_BRIDGE_DISABLED
        return result

    try:
        listing = list_issues(
            config,
            token=token,
            query=query,
            limit=limit,
            max_pages=max_pages,
            http_fn=http_fn,
        )
    except SentryApiError as exc:
        result["reasons"].append(str(exc))
        result.update(exc.as_dict())
        return result

    reconciler = reconcile_fn or incident_bridge.reconcile_incident
    result["inventory_complete"] = listing.get("inventory_complete", False)
    result["pages_fetched"] = listing.get("pages_fetched", 0)

    for issue in listing.get("issues", []):
        result["scanned"] += 1
        eligible, reason = should_bridge_issue(issue, config)
        if not eligible:
            result["skipped"] += 1
            result["results"].append(
                {
                    "sentry_issue_id": issue.get("id"),
                    "action": (
                        ACTION_SKIPPED_THRESHOLD
                        if "threshold" in reason
                        else ACTION_SKIPPED_STATUS
                    ),
                    "reason": reason,
                }
            )
            continue

        latest_event = None
        if fetch_events:
            try:
                events = get_issue_events(
                    config, issue["id"], token=token, limit=1, http_fn=http_fn
                )
                latest_event = events.get("latest_event")
            except SentryApiError as exc:
                # Event enrichment is best-effort; the issue itself still bridges.
                result["reasons"].append(
                    f"event fetch failed for {issue.get('id')}: {exc}"
                )

        observation = observation_from_issue(
            issue,
            config,
            gitea_org=gitea_org,
            gitea_repo=gitea_repo,
            latest_event=latest_event,
        )
        try:
            reconciled = reconciler(
                db,
                observation=observation,
                mappings=list(mappings or []),
                apply=bool(apply),
                create_issue_fn=create_issue_fn,
                comment_issue_fn=comment_issue_fn,
            )
        except Exception as exc:  # noqa: BLE001 - one bad issue must not abort the scan
            result["failed"] += 1
            result["results"].append(
                {
                    "sentry_issue_id": issue.get("id"),
                    "action": ACTION_FAILED,
                    "reason": incident_bridge.redact_text(exc),
                }
            )
            continue

        result["reconciled"] += 1
        result["results"].append(
            {
                "sentry_issue_id": issue.get("id"),
                "action": ACTION_RECONCILED,
                "outcome": reconciled.get("outcome"),
                "gitea_issue": reconciled.get("gitea_issue"),
                "existing_link": reconciled.get("existing_link"),
                "recurrence_comment": reconciled.get("recurrence_comment"),
                "reasons": reconciled.get("reasons"),
            }
        )

    result["success"] = result["failed"] == 0
    if not result["results"]:
        result["reasons"].append("no Sentry issues matched the scan window/policy")
    return result
