#!/usr/bin/env python3
"""TLS certificate health-check script for CleverAgents infrastructure.

Verifies that a given hostname's TLS certificate:
  - Is reachable via HTTPS
  - Presents a valid certificate trusted by the system CA bundle
  - Includes the target hostname as a Subject Alternative Name (SAN)
  - Has not expired and will not expire within the warning threshold

Usage:
    python scripts/check-tls-cert.py git.dev.cleveragents.com
    python scripts/check-tls-cert.py git.dev.cleveragents.com --port 443
    python scripts/check-tls-cert.py git.dev.cleveragents.com --warn-days 30
    python scripts/check-tls-cert.py git.dev.cleveragents.com git.cleveragents.com

Exit codes:
    0  All checks passed
    1  One or more checks failed (certificate error, expiry, SAN mismatch)
    2  Usage error (bad arguments)

This script is used by the ops team to diagnose TLS issues and by CI to
monitor certificate health.  See docs/development/ops-runbook.md for the
full certificate renewal procedure.
"""

from __future__ import annotations

import argparse
import datetime
import socket
import ssl
import sys
from collections.abc import Sequence
from dataclasses import dataclass, field

# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------


@dataclass
class CertCheckResult:
    """Result of a single TLS certificate check for one hostname."""

    hostname: str
    port: int
    ok: bool = True
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    subject_cn: str = ""
    sans: list[str] = field(default_factory=list)
    not_after: datetime.datetime | None = None
    days_remaining: int | None = None

    def add_error(self, msg: str) -> None:
        """Record a fatal error and mark the result as failed."""
        self.errors.append(msg)
        self.ok = False

    def add_warning(self, msg: str) -> None:
        """Record a non-fatal warning (does not affect ok status)."""
        self.warnings.append(msg)


# ---------------------------------------------------------------------------
# Certificate inspection helpers
# ---------------------------------------------------------------------------


def _parse_not_after(cert: dict) -> datetime.datetime | None:
    """Parse the ``notAfter`` field from a DER-decoded certificate dict.

    Returns a timezone-aware UTC datetime, or ``None`` if the field is
    absent or cannot be parsed.
    """
    not_after_str: str | None = cert.get("notAfter")
    if not not_after_str:
        return None
    try:
        # ssl module returns strings like "Apr  2 23:59:59 2026 GMT"
        dt = datetime.datetime.strptime(not_after_str, "%b %d %H:%M:%S %Y %Z")
        return dt.replace(tzinfo=datetime.UTC)
    except ValueError:
        return None


def _extract_sans(cert: dict) -> list[str]:
    """Extract Subject Alternative Names from a DER-decoded certificate dict.

    Returns a list of SAN values (e.g. ``["git.dev.cleveragents.com",
    "git.cleveragents.com"]``).  DNS SANs are returned without the
    ``DNS:`` prefix.
    """
    sans: list[str] = []
    for san_type, san_value in cert.get("subjectAltName", ()):
        if san_type == "DNS":
            sans.append(san_value)
    return sans


def _extract_cn(cert: dict) -> str:
    """Extract the Common Name (CN) from the certificate subject."""
    for rdn in cert.get("subject", ()):
        for attr_type, attr_value in rdn:
            if attr_type == "commonName":
                return attr_value
    return ""


def _hostname_matches_san(hostname: str, sans: list[str]) -> bool:
    """Return True if *hostname* matches any SAN entry.

    Supports exact matches and single-level wildcard patterns
    (e.g. ``*.cleveragents.com`` matches ``git.cleveragents.com`` but
    not ``a.b.cleveragents.com``).
    """
    hostname_lower = hostname.lower()
    for san in sans:
        san_lower = san.lower()
        if san_lower == hostname_lower:
            return True
        if san_lower.startswith("*."):
            # Wildcard: *.example.com matches foo.example.com only
            suffix = san_lower[1:]  # ".example.com"
            if (
                hostname_lower.endswith(suffix)
                and "." not in hostname_lower[: -len(suffix)]
            ):
                return True
    return False


# ---------------------------------------------------------------------------
# Core check function
# ---------------------------------------------------------------------------


def check_tls_certificate(
    hostname: str,
    port: int = 443,
    warn_days: int = 30,
    timeout: float = 10.0,
    ssl_context: ssl.SSLContext | None = None,
) -> CertCheckResult:
    """Perform a full TLS certificate check for *hostname*:*port*.

    Args:
        hostname:    The hostname to connect to and verify.
        port:        TCP port (default 443).
        warn_days:   Warn if the certificate expires within this many days.
        timeout:     Socket connection timeout in seconds.
        ssl_context: Optional custom SSLContext (used in tests to inject
                     mock behaviour).  When ``None``, a default context
                     with system CA verification is used.

    Returns:
        A :class:`CertCheckResult` describing the outcome.
    """
    result = CertCheckResult(hostname=hostname, port=port)

    # Build default SSL context if none provided
    ctx = ssl.create_default_context() if ssl_context is None else ssl_context

    # --- Attempt TLS handshake ---
    try:
        with (
            socket.create_connection((hostname, port), timeout=timeout) as raw_sock,
            ctx.wrap_socket(raw_sock, server_hostname=hostname) as tls_sock,
        ):
            cert = tls_sock.getpeercert()
    except ssl.SSLCertVerificationError as exc:
        reason = getattr(exc, "reason", None) or str(exc)
        result.add_error(f"TLS verification failed: {reason}")
        return result
    except ssl.SSLError as exc:
        result.add_error(f"TLS handshake error: {exc}")
        return result
    except TimeoutError:
        result.add_error(f"Connection timed out after {timeout}s")
        return result
    except OSError as exc:
        result.add_error(f"Connection failed: {exc}")
        return result

    if cert is None:
        result.add_error("No certificate returned by server")
        return result

    # --- Parse certificate fields ---
    result.subject_cn = _extract_cn(cert)
    result.sans = _extract_sans(cert)
    result.not_after = _parse_not_after(cert)

    # --- SAN check ---
    if not _hostname_matches_san(hostname, result.sans):
        result.add_error(
            f"Hostname '{hostname}' not found in certificate SANs: "
            f"{result.sans!r}. The certificate may be missing this hostname "
            "or SNI routing is misconfigured."
        )

    # --- Expiry check ---
    if result.not_after is not None:
        now = datetime.datetime.now(tz=datetime.UTC)
        delta = result.not_after - now
        result.days_remaining = delta.days
        if delta.total_seconds() <= 0:
            expiry = result.not_after.strftime("%Y-%m-%d %H:%M:%S UTC")
            result.add_error(f"Certificate expired on {expiry}")
        elif delta.days <= warn_days:
            expiry_date = result.not_after.strftime("%Y-%m-%d")
            result.add_warning(
                f"Certificate expires in {delta.days} day(s) "
                f"({expiry_date}). "
                f"Renewal recommended (threshold: {warn_days} days)."
            )
    else:
        result.add_warning("Could not determine certificate expiry date")

    return result


# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------


def _print_result(result: CertCheckResult) -> None:
    """Print a human-readable summary of a :class:`CertCheckResult`."""
    status = "OK" if result.ok else "FAIL"
    print(f"\n[{status}] {result.hostname}:{result.port}")
    if result.subject_cn:
        print(f"  Subject CN : {result.subject_cn}")
    if result.sans:
        print(f"  SANs       : {', '.join(result.sans)}")
    if result.not_after:
        days = result.days_remaining
        expiry = result.not_after.strftime("%Y-%m-%d %H:%M:%S UTC")
        print(f"  Expires    : {expiry} ({days} day(s) remaining)")
    for err in result.errors:
        print(f"  ERROR      : {err}")
    for warn in result.warnings:
        print(f"  WARNING    : {warn}")


# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------


def main(argv: Sequence[str] | None = None) -> int:
    """Parse arguments and run TLS checks.  Returns the process exit code."""
    parser = argparse.ArgumentParser(
        description="Check TLS certificate validity for one or more hostnames.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )
    parser.add_argument(
        "hostnames",
        nargs="+",
        metavar="HOSTNAME",
        help="One or more hostnames to check (e.g. git.dev.cleveragents.com)",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=443,
        help="HTTPS port to connect to (default: 443)",
    )
    parser.add_argument(
        "--warn-days",
        type=int,
        default=30,
        metavar="DAYS",
        help="Warn if certificate expires within DAYS days (default: 30)",
    )
    parser.add_argument(
        "--timeout",
        type=float,
        default=10.0,
        metavar="SECONDS",
        help="Connection timeout in seconds (default: 10)",
    )

    args = parser.parse_args(argv)

    all_ok = True
    for hostname in args.hostnames:
        result = check_tls_certificate(
            hostname=hostname,
            port=args.port,
            warn_days=args.warn_days,
            timeout=args.timeout,
        )
        _print_result(result)
        if not result.ok:
            all_ok = False

    print()
    if all_ok:
        print("All TLS checks PASSED")
        return 0
    else:
        print("One or more TLS checks FAILED")
        return 1


if __name__ == "__main__":
    sys.exit(main())
