"""Bluesky search via AT Protocol (requires app password).

Uses bsky.social for auth and api.bsky.app for post search (the canonical
authenticated AppView). The previous default `public.api.bsky.app` is the
unauthenticated public mirror, which BunnyCDN now blocks for searchPosts
regardless of auth header (verified 2026-05-04). Override the search host
via BSKY_SEARCH_HOST env var if Bluesky migrates infrastructure again.

Requires BSKY_HANDLE and BSKY_APP_PASSWORD env vars. App passwords are
19-char xxxx-xxxx-xxxx-xxxx; generate at bsky.app/settings/app-passwords.
The createSession endpoint accepts main-account passwords too, but they're
bad hygiene (no scope, can't revoke individually).
"""

import math
import os
import re
import sys
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

from . import http, log

BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"
_DEFAULT_BSKY_SEARCH_HOST = "api.bsky.app"


def _resolve_search_url(config: Optional[Dict[str, Any]] = None) -> str:
    """Resolve the Bluesky search URL with BSKY_SEARCH_HOST override.

    Default is api.bsky.app. Override via BSKY_SEARCH_HOST in shell env or
    .env file. The project's env.py loads .env into config but not into
    os.environ, so check both — same hybrid pattern as last30days.py for
    LAST30DAYS_STORE.

    Hardens user-supplied host values against three common mis-configurations:
    whitespace (e.g. " api.bsky.app "), embedded path components (e.g.
    "api.bsky.app/xrpc/proxy") that would double the /xrpc/ segment, and
    embedded scheme prefixes (e.g. "https://api.bsky.app"). On any of these
    we log a warning and fall back to the default rather than building an
    invalid URL with an opaque downstream error.
    """
    config = config or {}
    raw = (
        os.environ.get("BSKY_SEARCH_HOST")
        or config.get("BSKY_SEARCH_HOST")
        or _DEFAULT_BSKY_SEARCH_HOST
    )
    host = raw.strip().rstrip("/")
    # Strip embedded scheme so users who paste full URLs do not break the f-string.
    for prefix in ("https://", "http://"):
        if host.lower().startswith(prefix):
            host = host[len(prefix):]
            break
    if not host or "/" in host or " " in host:
        # Embedded path or whitespace remains — don't trust it. Default + log.
        if raw != _DEFAULT_BSKY_SEARCH_HOST:
            _log(
                f"BSKY_SEARCH_HOST={raw!r} is not a bare hostname; "
                f"falling back to default {_DEFAULT_BSKY_SEARCH_HOST!r}"
            )
        host = _DEFAULT_BSKY_SEARCH_HOST
    return f"https://{host}/xrpc/app.bsky.feed.searchPosts"


# App-password format: xxxx-xxxx-xxxx-xxxx (19 chars, lowercase alphanumeric
# with three hyphens at fixed positions).
_APP_PASSWORD_RE = re.compile(r"^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$")


def _validate_app_password_format(value) -> bool:
    """Return True if value matches Bluesky's 19-char app-password format.

    False for non-strings (None, int, list) so callers passing config dict
    values directly don't crash. Detect-but-not-gate: the createSession
    endpoint also accepts main-account passwords, so failing this check is
    a hygiene smell, not a hard error.
    """
    if not isinstance(value, str):
        return False
    return bool(_APP_PASSWORD_RE.fullmatch(value))


DEPTH_CONFIG = {
    "quick": 15,
    "default": 30,
    "deep": 60,
}

# Module-level token cache (valid for the lifetime of a single research run)
_cached_token: Optional[str] = None
_token_created_at: float = 0.0
_session_error: Optional[str] = None
_TOKEN_MAX_AGE_SECONDS = 5400  # 90 minutes (conservative, tokens last ~2 hours)


def _log(msg: str):
    log.source_log("Bluesky", msg, tty_only=False)


def _create_session(handle: str, app_password: str) -> Optional[str]:
    """Create an AT Protocol session and return the access token.

    Args:
        handle: Bluesky handle (e.g. user.bsky.social)
        app_password: App password from bsky.app/settings/app-passwords

    Returns:
        Access JWT string, or None on failure. Sets _session_error on failure.
    """
    global _cached_token, _token_created_at, _session_error
    if _cached_token and (time.monotonic() - _token_created_at < _TOKEN_MAX_AGE_SECONDS):
        return _cached_token
    if _cached_token:
        _log("Session token expired, re-authenticating")
        _cached_token = None
        _token_created_at = 0.0

    try:
        response = http.request(
            "POST",
            BSKY_SESSION_URL,
            json_data={"identifier": handle, "password": app_password},
            timeout=15,
        )
        token = response.get("accessJwt")
        if token:
            _cached_token = token
            _token_created_at = time.monotonic()
            _session_error = None
            _log("Session created successfully")
            return token
        _log("No accessJwt in session response")
        _session_error = "No accessJwt in session response"
        return None
    except http.HTTPError as e:
        if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
            _session_error = "Cloudflare blocked the request (403 Forbidden). This is a network-level block, not an auth issue. Try a different network or VPN."
        elif e.status_code == 401:
            _session_error = "Invalid credentials (401 Unauthorized). Check BSKY_HANDLE and BSKY_APP_PASSWORD."
        else:
            _session_error = f"Session request failed: {e}"
        _log(f"Session creation failed: {_session_error}")
        return None
    except Exception as e:
        _session_error = f"Session request failed: {type(e).__name__}: {e}"
        _log(f"Session creation failed: {_session_error}")
        return None


def _reset_session_cache() -> None:
    global _cached_token, _token_created_at, _session_error
    _cached_token = None
    _token_created_at = 0.0
    _session_error = None


def _extract_core_subject(topic: str) -> str:
    """Extract core subject from verbose query for Bluesky search."""
    from .query import SOCIAL_NOISE, extract_core_subject
    return extract_core_subject(topic, noise=SOCIAL_NOISE)


def _parse_date(item: Dict[str, Any]) -> Optional[str]:
    """Parse date from Bluesky post to YYYY-MM-DD.

    AT Protocol uses ISO 8601 format in indexedAt and createdAt fields.
    """
    for key in ("indexedAt", "createdAt"):
        val = item.get(key)
        if val and isinstance(val, str):
            try:
                dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
                return dt.strftime("%Y-%m-%d")
            except (ValueError, TypeError):
                pass
    return None


def search_bluesky(
    topic: str,
    from_date: str,
    to_date: str,
    depth: str = "default",
    config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Search Bluesky via AT Protocol API.

    Args:
        topic: Search topic
        from_date: Start date (YYYY-MM-DD)
        to_date: End date (YYYY-MM-DD)
        depth: 'quick', 'default', or 'deep'
        config: Config dict with BSKY_HANDLE and BSKY_APP_PASSWORD

    Returns:
        Dict with 'posts' list from AT Protocol response.
    """
    config = config or {}
    handle = config.get("BSKY_HANDLE", "")
    app_password = config.get("BSKY_APP_PASSWORD", "")

    if not handle or not app_password:
        return {"posts": [], "error": "Bluesky credentials not configured"}

    # One-shot hygiene warning if BSKY_APP_PASSWORD is not in app-password
    # form. createSession accepts main-account passwords too — but main
    # passwords have no scope (full account access), can't be revoked
    # individually, and rotating them breaks every service that holds them.
    # We warn but do not gate, matching the project's detect-don't-block
    # philosophy elsewhere.
    if not _validate_app_password_format(app_password):
        _log(
            "BSKY_APP_PASSWORD does not look like an app password "
            "(expected xxxx-xxxx-xxxx-xxxx, 19 chars). It may be a main "
            "account password — those work but are bad hygiene. Generate "
            "an app password at https://bsky.app/settings/app-passwords"
        )

    count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
    core_topic = _extract_core_subject(topic)

    _log(f"Searching for '{core_topic}' (depth={depth}, limit={count})")

    from urllib.parse import urlencode
    params = {
        "q": core_topic,
        "limit": str(min(count, 100)),
        "sort": "top",
    }
    url = f"{_resolve_search_url(config)}?{urlencode(params)}"

    def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]:
        token = _create_session(handle, app_password)
        if not token:
            error_msg = _session_error or "Bluesky session creation failed (unknown error)"
            return None, error_msg
        try:
            response = http.request(
                "GET", url,
                headers={"Authorization": f"Bearer {token}"},
                timeout=30,
            )
            return response, None
        except http.HTTPError as e:
            _log(f"Search failed: {e}")
            if e.status_code == 401:
                _reset_session_cache()
                return None, "refresh"
            if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
                return None, "Bluesky search blocked by Cloudflare (403). This is a network-level block - try a different network or VPN."
            return None, f"Bluesky search failed: {e}"
        except Exception as e:
            _log(f"Search failed: {e}")
            return None, f"Bluesky search failed: {type(e).__name__}: {e}"

    response, error_msg = _auth_and_search()
    if error_msg == "refresh":
        _log("Session expired; recreating token and retrying once")
        response, error_msg = _auth_and_search()
    if error_msg:
        return {"posts": [], "error": error_msg}
    if response is None:
        return {"posts": [], "error": "Bluesky search failed (unknown error)"}

    posts = response.get("posts", [])
    _log(f"Found {len(posts)} posts")
    return response


def parse_bluesky_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Parse AT Protocol response into normalized item dicts.

    Returns:
        List of item dicts ready for normalization.
    """
    posts = response.get("posts", [])
    items = []

    for i, post in enumerate(posts):
        record = post.get("record") or {}
        text = record.get("text") or ""

        author = post.get("author") or {}
        handle = author.get("handle") or ""
        display_name = author.get("displayName") or handle

        # Post URI -> URL
        # URI format: at://did:plc:xxx/app.bsky.feed.post/rkey
        uri = post.get("uri") or ""
        rkey = uri.rsplit("/", 1)[-1] if uri else ""
        url = f"https://bsky.app/profile/{handle}/post/{rkey}" if handle and rkey else ""

        likes = post.get("likeCount") or 0
        reposts = post.get("repostCount") or 0
        replies = post.get("replyCount") or 0
        quotes = post.get("quoteCount") or 0

        date_str = _parse_date(post) or _parse_date(record)

        # Relevance: position-based (AT Protocol sorts by relevance with sort=top)
        rank_score = max(0.3, 1.0 - (i * 0.02))
        engagement_boost = min(0.2, math.log1p(likes + reposts) / 40)
        relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)

        items.append({
            "handle": handle,
            "display_name": display_name,
            "text": text,
            "url": url,
            "date": date_str,
            "engagement": {
                "likes": likes,
                "reposts": reposts,
                "replies": replies,
                "quotes": quotes,
            },
            "relevance": round(relevance, 2),
            "why_relevant": f"Bluesky: @{handle}: {text[:60]}" if text else f"Bluesky: {handle}",
        })

    return items
