# aiops_sdk/integrations/flask.py

import os
import traceback
import re
from flask import got_request_exception, request
from ..exceptions import capture_exception
from ..payload import base_payload
from ..client import send_async
from ..config import Config


# ── Noise filters ──────────────────────────────────────────────────────────────
# These are evaluated before any status-code check so the host app pays
# no per-request cost for legitimate high-frequency paths.

# HTTP methods that never warrant an incident.
#   OPTIONS — CORS preflight; browsers send these automatically, always noise.
#   HEAD    — HTTP spec identical to GET but no response body; never an app error.
_SKIP_METHODS = frozenset({"OPTIONS", "HEAD"})

# Health/monitoring paths that load-balancers and Kubernetes probes hit every
# 15-30 seconds.  A non-200 here is normal during startup/shutdown — not a bug.
# Users can extend this list without code changes via AIOPS_SKIP_PATHS env var
# (comma-separated, e.g. AIOPS_SKIP_PATHS=/admin/health,/internal/ping).
_DEFAULT_SKIP_PATHS = frozenset({
    "/health", "/healthz", "/ping", "/ready", "/alive",
    "/readiness", "/liveness", "/metrics", "/status",
    "/favicon.ico", "/robots.txt",
})
_EXTRA_SKIP_PATHS = frozenset(
    p.strip()
    for p in os.environ.get("AIOPS_SKIP_PATHS", "").split(",")
    if p.strip()
)
_SKIP_PATHS = _DEFAULT_SKIP_PATHS | _EXTRA_SKIP_PATHS

# Static file extensions — a JS or CSS file returning a 500 is never an
# application logic bug worth paging anyone about.
_STATIC_EXTENSIONS = frozenset({
    ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".gif",
    ".svg", ".woff", ".woff2", ".ttf", ".eot", ".map", ".webp",
})


def _infer_error_type(message: str) -> str:
    """
    Infer a Python exception type name from an error message string.
    Used when the real exception was swallowed by user code and only
    the str(e) survives in the JSON response body.
    """
    if not message:
        return "HTTPError"
    msg = message.lower()
    if "nonetype" in msg or "object has no attribute" in msg:
        return "AttributeError"
    if "keyerror" in msg or re.search(r"key ['\"]?\w+['\"]? not found", msg):
        return "KeyError"
    if "typeerror" in msg or "type error" in msg:
        return "TypeError"
    if "valueerror" in msg or "value error" in msg:
        return "ValueError"
    if "indexerror" in msg or "list index out of range" in msg:
        return "IndexError"
    if "connection" in msg and ("refused" in msg or "failed" in msg or "reset" in msg):
        return "ConnectionError"
    if "timeout" in msg:
        return "TimeoutError"
    if "permission" in msg or "access denied" in msg:
        return "PermissionError"
    # Database / authentication patterns (MongoDB Atlas, PyMongo, SQLAlchemy, etc.)
    if "authentication" in msg or "bad auth" in msg or "authenticationfailed" in msg:
        return "AuthenticationError"
    if "operationfailure" in msg or "pymongo" in msg or "mongoerror" in msg:
        return "DatabaseError"
    if "integrityerror" in msg or "unique constraint" in msg or "duplicate key" in msg:
        return "IntegrityError"
    if "operationalerror" in msg and ("database" in msg or "sql" in msg):
        return "DatabaseError"
    return "HTTPError"


def init_flask(app):
    """
    Flask integration for AIOps SDK.

    Captures:
    1. Unhandled Flask exceptions (via got_request_exception signal)
    2. Relevant HTTP error responses (4xx / 5xx) via after_request

    For case 2, when the actual Python exception was caught by user code
    (e.g., `except Exception as e: return jsonify({"message": str(e)}), 500`),
    we extract the real error from the JSON response body so downstream
    analysis gets the actual error string rather than the generic HTTP message.

    Noise filtering (no incidents created for):
    - OPTIONS and HEAD requests
    - Health-check / monitoring paths (/health, /ping, /metrics, etc.)
    - Static file extensions (.js, .css, .ico, etc.)
    - HTTP 404 (expected client behaviour)
    - HTTP 429 (rate-limiting is working-as-intended, not a server error)
    """

    # --------------------------------------------------
    # 1️⃣ Real Flask exceptions (propagated, not caught by user code)
    # --------------------------------------------------
    @got_request_exception.connect_via(app)
    def _handle_exception(sender, exception, **extra):
        # Mark this request so _after_request skips it — the real exception
        # object (with full stack trace) is already captured here. Without
        # this guard, the same error would be sent twice: once with the real
        # exception and traceback, and again as a generic HTTP 500.
        try:
            from flask import g
            g._aiops_exception_captured = True
        except Exception:
            pass
        capture_exception(exception)

    # --------------------------------------------------
    # 2️⃣ HTTP outcome-based failures
    # --------------------------------------------------
    @app.after_request
    def _after_request(response):
        try:
            # ── Auto-detect this server's own base URL on the first request ──
            # Reads Host + scheme from standard headers so it works correctly
            # whether the app is accessed directly or behind a reverse proxy.
            # Once set, _detected_base_url is never overwritten — class-level
            # variable acts as a write-once cache for the lifetime of the process.
            if Config._detected_base_url is None:
                try:
                    # X-Forwarded-* headers are set by nginx/load-balancers/cloudflare.
                    # Fall back to raw request values for direct connections.
                    proto = request.headers.get("X-Forwarded-Proto") or request.scheme
                    host  = request.headers.get("X-Forwarded-Host")  or request.host
                    Config._detected_base_url = f"{proto}://{host}"
                except Exception:
                    pass

            # ── Noise filters — evaluated before status code ───────────────────
            # Skip non-incident HTTP methods (CORS preflights, HEAD probes).
            if request.method in _SKIP_METHODS:
                return response
            # Skip health-check and monitoring paths.
            if request.path in _SKIP_PATHS:
                return response
            # Skip static asset paths (check file extension only — no I/O).
            _, ext = os.path.splitext(request.path)
            if ext.lower() in _STATIC_EXTENSIONS:
                return response

            status = response.status_code

            # Capture server errors, auth failures, and gateway errors.
            # Excluded:
            #   404 — expected client behaviour (path not found), not a server bug
            #   429 — rate-limiting is working as intended, not an error state
            if status in (400, 401, 403, 500, 502, 503, 504):
                # If _handle_exception already fired for this request, the real
                # exception (with full traceback) was already sent — skip here
                # to avoid creating a duplicate, lower-quality incident.
                try:
                    from flask import g
                    if getattr(g, '_aiops_exception_captured', False):
                        return response
                except Exception:
                    pass

                # Try to get active exception traceback (only available if not caught by user code)
                tb = traceback.format_exc()
                stack_trace = tb if tb and "NoneType: None" not in tb else ""

                # ── Extract real error from JSON response body ────────────────
                # When the exception was caught by `except Exception as e: return jsonify(...), 500`,
                # str(e) is typically embedded in the response JSON as "message", "error", or "detail".
                # This gives us the actual exception text instead of the generic HTTP message.
                actual_message = None
                inferred_error_type = "HTTPError"
                try:
                    resp_data = response.get_json(silent=True)
                    if isinstance(resp_data, dict):
                        raw = (
                            resp_data.get("message") or
                            resp_data.get("error") or
                            resp_data.get("detail") or
                            resp_data.get("msg")
                        )
                        if isinstance(raw, str):
                            actual_message = raw.strip() or None
                    if actual_message:
                        inferred_error_type = _infer_error_type(actual_message)
                except Exception:
                    pass

                final_message = (
                    actual_message
                    or f"{request.method} {request.path} returned HTTP {status}"
                )

                payload = base_payload()
                payload.update({
                    "signal_type": "http_error",
                    "status_code": status,
                    "error_type": inferred_error_type,
                    "message": final_message,
                    "stack_trace": stack_trace,
                    "request_path": request.path,
                    "source": "http"
                })

                # send_async: returns immediately, never delays the HTTP response.
                # The payload is delivered by the background worker thread.
                send_async("/v1/sdk/exception", payload)

        except Exception:
            # Never impact the host application — all SDK failures are silent.
            pass

        return response
