"""Step definitions for tls_certificate_check.feature.

Tests the ``scripts/check-tls-cert.py`` TLS certificate health-check script
using injected SSL contexts and mock socket connections so no real network
connections are made.  All scenarios are tagged ``@tdd_issue @tdd_issue_1543``
as permanent regression guards for the TLS handshake failure on
``git.dev.cleveragents.com`` (issue #1543).
"""

from __future__ import annotations

import datetime
import importlib.util as _ilu
import ssl
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

from behave import given, then, when
from behave.runner import Context

# ---------------------------------------------------------------------------
# Import the module under test
# ---------------------------------------------------------------------------

_SCRIPTS_DIR = Path(__file__).parent.parent.parent / "scripts"

# Import using importlib to handle the hyphenated filename.
# We must set __name__ and __package__ on the spec so that dataclasses
# can resolve the module's namespace correctly.
_spec = _ilu.spec_from_file_location(
    "check_tls_cert",
    _SCRIPTS_DIR / "check-tls-cert.py",
)
assert _spec is not None and _spec.loader is not None
_mod = _ilu.module_from_spec(_spec)
# Register the module in sys.modules BEFORE exec so that dataclasses
# (and other introspection tools) can look it up by __module__ name.
sys.modules["check_tls_cert"] = _mod
_spec.loader.exec_module(_mod)  # type: ignore[union-attr]

check_tls_certificate = _mod.check_tls_certificate
_hostname_matches_san = _mod._hostname_matches_san
CertCheckResult = _mod.CertCheckResult


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_cert(
    cn: str,
    sans: list[str],
    not_after: datetime.datetime,
) -> dict:
    """Build a minimal certificate dict in the format returned by ssl.getpeercert()."""
    san_tuples = tuple(("DNS", san) for san in sans)
    not_after_str = not_after.strftime("%b %d %H:%M:%S %Y GMT")
    return {
        "subject": ((("commonName", cn),),),
        "subjectAltName": san_tuples,
        "notAfter": not_after_str,
    }


def _make_mock_ssl_context(cert: dict) -> MagicMock:
    """Return a mock SSLContext whose wrap_socket yields a socket with *cert*."""
    mock_tls_sock = MagicMock()
    mock_tls_sock.getpeercert.return_value = cert
    mock_tls_sock.__enter__ = MagicMock(return_value=mock_tls_sock)
    mock_tls_sock.__exit__ = MagicMock(return_value=False)

    mock_ctx = MagicMock(spec=ssl.SSLContext)
    mock_ctx.wrap_socket.return_value = mock_tls_sock
    return mock_ctx


def _make_mock_raw_sock() -> MagicMock:
    """Return a mock raw socket for use as a context manager."""
    mock_raw = MagicMock()
    mock_raw.__enter__ = MagicMock(return_value=mock_raw)
    mock_raw.__exit__ = MagicMock(return_value=False)
    return mock_raw


# ---------------------------------------------------------------------------
# Given — certificate setup
# ---------------------------------------------------------------------------


@given('a TLS certificate for "{cn}" with SANs "{sans_str}"')
def step_cert_with_sans(context: Context, cn: str, sans_str: str) -> None:
    """Set up a mock certificate with the given CN and SANs."""
    context.cert_cn = cn
    context.cert_sans = [s.strip() for s in sans_str.split(",")]
    # Default expiry: 90 days from now (overridden by subsequent Given steps)
    context.cert_not_after = datetime.datetime.now(
        tz=datetime.UTC
    ) + datetime.timedelta(days=90)
    context.ssl_error = None
    context.timeout_error = False
    context.connection_error = None


@given("the certificate expires in {days:d} days")
def step_cert_expires_in(context: Context, days: int) -> None:
    """Set the certificate expiry to *days* days from now."""
    context.cert_not_after = datetime.datetime.now(
        tz=datetime.UTC
    ) + datetime.timedelta(days=days)


@given("the certificate expired {days:d} days ago")
def step_cert_expired_ago(context: Context, days: int) -> None:
    """Set the certificate expiry to *days* days in the past."""
    context.cert_not_after = datetime.datetime.now(
        tz=datetime.UTC
    ) - datetime.timedelta(days=days)


@given('the TLS connection raises an SSLError "{reason}"')
def step_ssl_error(context: Context, reason: str) -> None:
    """Configure the mock to raise an SSLCertVerificationError."""
    context.ssl_error = ssl.SSLCertVerificationError(reason)
    context.connection_error = None
    context.timeout_error = False


@given("the TLS connection times out")
def step_timeout_error(context: Context) -> None:
    """Configure the mock to raise a TimeoutError."""
    context.timeout_error = True
    context.ssl_error = None
    context.connection_error = None


@given("the TLS connection is refused")
def step_connection_refused(context: Context) -> None:
    """Configure the mock to raise a ConnectionRefusedError."""
    context.connection_error = ConnectionRefusedError("Connection refused")
    context.ssl_error = None
    context.timeout_error = False


# ---------------------------------------------------------------------------
# When — run the check
# ---------------------------------------------------------------------------


def _run_check(context: Context, hostname: str, warn_days: int = 30) -> None:
    """Execute check_tls_certificate with appropriate mocks."""
    ssl_error = getattr(context, "ssl_error", None)
    timeout_error = getattr(context, "timeout_error", False)
    connection_error = getattr(context, "connection_error", None)

    if ssl_error is not None:
        # Simulate SSLCertVerificationError during wrap_socket
        mock_ctx = MagicMock(spec=ssl.SSLContext)
        mock_ctx.wrap_socket.side_effect = ssl_error
        mock_raw = _make_mock_raw_sock()
        with patch("socket.create_connection", return_value=mock_raw):
            context.check_result = check_tls_certificate(
                hostname=hostname,
                warn_days=warn_days,
                ssl_context=mock_ctx,
            )
    elif timeout_error:
        # Simulate TimeoutError during create_connection
        with patch("socket.create_connection", side_effect=TimeoutError("timed out")):
            context.check_result = check_tls_certificate(
                hostname=hostname,
                warn_days=warn_days,
            )
    elif connection_error is not None:
        # Simulate OSError during create_connection
        with patch("socket.create_connection", side_effect=connection_error):
            context.check_result = check_tls_certificate(
                hostname=hostname,
                warn_days=warn_days,
            )
    else:
        # Normal path: inject a mock certificate
        cert = _make_cert(
            cn=context.cert_cn,
            sans=context.cert_sans,
            not_after=context.cert_not_after,
        )
        mock_ctx = _make_mock_ssl_context(cert)
        mock_raw = _make_mock_raw_sock()
        with patch("socket.create_connection", return_value=mock_raw):
            context.check_result = check_tls_certificate(
                hostname=hostname,
                warn_days=warn_days,
                ssl_context=mock_ctx,
            )


@when('the TLS check runs for hostname "{hostname}"')
def step_run_check(context: Context, hostname: str) -> None:
    """Run the TLS check for *hostname* with default warn-days (30)."""
    _run_check(context, hostname, warn_days=30)


@when('the TLS check runs for hostname "{hostname}" with warn-days {warn_days:d}')
def step_run_check_with_warn_days(
    context: Context, hostname: str, warn_days: int
) -> None:
    """Run the TLS check for *hostname* with a custom warn-days threshold."""
    _run_check(context, hostname, warn_days=warn_days)


@when('I check if hostname "{hostname}" matches SANs "{sans_str}"')
def step_check_san_match(context: Context, hostname: str, sans_str: str) -> None:
    """Invoke _hostname_matches_san directly."""
    sans = [s.strip() for s in sans_str.split(",")]
    context.san_match_result = _hostname_matches_san(hostname, sans)


# ---------------------------------------------------------------------------
# Then — assertions
# ---------------------------------------------------------------------------


@then("the check result should be a success")
def step_check_success(context: Context) -> None:
    """Assert the check result is OK."""
    result: Any = context.check_result
    assert result.ok, f"Expected TLS check to succeed but got errors: {result.errors!r}"


@then("the check result should be a failure")
def step_check_failure(context: Context) -> None:
    """Assert the check result is not OK."""
    result: Any = context.check_result
    assert not result.ok, (
        f"Expected TLS check to fail but it succeeded (warnings: {result.warnings!r})"
    )


@then('the check error should mention "{text}"')
def step_check_error_mentions(context: Context, text: str) -> None:
    """Assert at least one error message contains *text*."""
    result: Any = context.check_result
    combined = " ".join(result.errors)
    assert text.lower() in combined.lower(), (
        f"Expected error containing {text!r} but got errors: {result.errors!r}"
    )


@then('the check warning should mention "{text}"')
def step_check_warning_mentions(context: Context, text: str) -> None:
    """Assert at least one warning message contains *text*."""
    result: Any = context.check_result
    combined = " ".join(result.warnings)
    assert text.lower() in combined.lower(), (
        f"Expected warning containing {text!r} but got warnings: {result.warnings!r}"
    )


@then("the check has no warnings")
def step_check_no_warnings(context: Context) -> None:
    """Assert the check result has no warnings."""
    result: Any = context.check_result
    assert not result.warnings, f"Expected no warnings but got: {result.warnings!r}"


@then("the SAN match result should be True")
def step_san_match_true(context: Context) -> None:
    """Assert _hostname_matches_san returned True."""
    assert context.san_match_result is True, (
        f"Expected SAN match to be True but got {context.san_match_result!r}"
    )


@then("the SAN match result should be False")
def step_san_match_false(context: Context) -> None:
    """Assert _hostname_matches_san returned False."""
    assert context.san_match_result is False, (
        f"Expected SAN match to be False but got {context.san_match_result!r}"
    )
