"""Robot Framework Listener v3 for TDD issue-capture tag handling.

Implements the three-tag TDD issue-capture system for Robot Framework
integration tests, paralleling the Behave implementation in
``features/environment.py``.

Three-Tag System (see CONTRIBUTING.md > TDD Issue Test Tags)
-----------------------------------------------------------
``tdd_issue``
    Generic filter tag.  Present on ALL TDD issue tests.  Used to list,
    filter, and count TDD issue tests across the codebase (e.g.,
    ``--include tdd_issue``).

``tdd_issue_<N>``
    Issue reference tag (e.g., ``tdd_issue_123``).  Links the test to the
    specific ``Type/Bug`` issue it captures.  N is the bug issue number.
    Permanent -- never removed.

``tdd_expected_fail``
    Behavioral switch.  When present, the test result is inverted:

    * A **failing** test (bug still exists) is reported as **passed**.
    * A **passing** test (bug was fixed without removing the tag) is
      reported as **failed** with a clear guidance message.

    Temporary -- removed by the bug-fix developer when the fix is
    implemented.

Tag Validation Rules
--------------------
* ``tdd_issue_<N>`` requires ``tdd_issue`` to also be present.
* ``tdd_expected_fail`` requires both ``tdd_issue`` and at least one
  ``tdd_issue_<N>``.

Guard Logic (parallels Behave ``apply_tdd_inversion()``)
--------------------------------------------------------
Before inverting a ``tdd_expected_fail`` result, three guards are
checked.  If any guard triggers, the original result is preserved
(inversion is skipped) because the failure is not evidence about the
captured bug:

1. **Setup/teardown error guard** — if ``result.setup`` or
   ``result.teardown`` has status ``FAIL``, the test body did not
   execute (or cleanup failed).  The failure is infrastructure, not
   the bug.  Detected via ``_has_setup_teardown_failure()``.

2. **Non-assertion failure guard** — if ``result.message`` contains
   patterns characteristic of infrastructure errors (e.g.,
   ``No keyword with name``, ``TypeError:``, ``ImportError:``,
   ``TimeoutError:``, ``Connection refused``), the failure is not a
   legitimate assertion failure from the captured bug.  Detected via
   ``_is_infrastructure_error()``.

3. **Dry-run guard** — if all body keywords have status ``NOT RUN``
   (Robot Framework dry-run mode), no test actually executed, so the
   result is meaningless.  Inversion is skipped.

These guards mirror the Behave ``apply_tdd_inversion()`` guards in
``features/environment.py`` (hook-error guard, dry-run guard,
non-assertion exception guard).

Implementation Notes
--------------------
The listener uses the Robot Framework Listener v3 API with module-level
``start_test`` and ``end_test`` functions.  Tag validation happens in
``start_test``; result inversion happens in ``end_test``.  Validation
errors are stored in ``_validation_errors`` keyed by test ``full_name``
and applied in ``end_test`` to ensure the error message is visible in
reports.

Tags are read from ``data.tags`` (the static test definition) rather
than ``result.tags``.  This is intentional: TDD tags are declarative
metadata that must be present in the source file.  Runtime tag
modification via ``Set Tags`` / ``Remove Tags`` keywords is not
supported for TDD tags — they must be statically declared.

An idempotency guard (``_processed_tests``) prevents double-inversion
if the listener is loaded more than once (e.g., ``--listener`` specified
both in noxfile.py and via user arguments).

Registration: The listener is registered via ``--listener`` in the nox
``integration_tests`` and ``slow_integration_tests`` sessions.
"""

from __future__ import annotations

import logging
import re

from robot.result.model import TestCase as ResultTestCase
from robot.running.model import TestCase as RunningTestCase

ROBOT_LISTENER_API_VERSION = 3

_TDD_ISSUE_N_RE = re.compile(r"tdd_issue_\d+")

_logger = logging.getLogger("cleveragents.testing.robot_tdd_tags")

# Validation errors detected in start_test are stored here keyed by test
# longname so that end_test can override whatever result the test produced.
_validation_errors: dict[str, str] = {}

# Idempotency guard: prevents double-invocation if the listener is loaded
# more than once in the same process (e.g., --listener specified twice or
# via both noxfile and user args).  Each test is processed at most once.
_processed_tests: set[str] = set()


def _validate_tdd_tags(tags: set[str]) -> str | None:
    """Validate TDD issue-capture tag combinations.

    Returns an error message string if the tag set is inconsistent, or
    ``None`` if the tags are valid.

    Design note: this function returns ``str | None`` rather than raising
    ``ValueError`` (as the Behave counterpart ``validate_tdd_tags`` does)
    because the Robot Framework Listener v3 API does not propagate
    exceptions cleanly from ``start_test``.  Returning a string allows
    ``start_test`` to store the error for ``end_test`` to apply as a test
    failure, giving better diagnostics in the Robot output XML.

    Rules (from CONTRIBUTING.md > TDD Issue Test Tags):
    * ``tdd_issue_<N>`` requires ``tdd_issue`` to also be present.
    * ``tdd_expected_fail`` requires both ``tdd_issue`` and at least one
      ``tdd_issue_<N>``.

    Args:
        tags: The tags on the test case (lowercased).

    Returns:
        An error message string if validation fails, ``None`` otherwise.
    """
    has_tdd_issue = "tdd_issue" in tags
    has_tdd_issue_n = any(_TDD_ISSUE_N_RE.fullmatch(t) for t in tags)
    has_expected_fail = "tdd_expected_fail" in tags

    # Note: error messages reference tag names without the ``@`` prefix used
    # by Behave (e.g., ``tdd_issue`` not ``@tdd_issue``).  Robot Framework tags
    # do not use the ``@`` prefix — this divergence from the Behave error
    # messages is intentional and matches each framework's convention.
    if has_tdd_issue_n and not has_tdd_issue:
        issue_n_tags = sorted(t for t in tags if _TDD_ISSUE_N_RE.fullmatch(t))
        return (
            f"Test has {', '.join(issue_n_tags)} but is missing the required "
            f"tdd_issue tag. All TDD issue tests must include tdd_issue. "
            f"See CONTRIBUTING.md > TDD Issue Test Tags."
        )

    if has_expected_fail:
        missing: list[str] = []
        if not has_tdd_issue:
            missing.append("tdd_issue")
        if not has_tdd_issue_n:
            missing.append("tdd_issue_<N>")
        if missing:
            return (
                f"Test has tdd_expected_fail but is missing required "
                f"tag(s): {', '.join(missing)}. tdd_expected_fail requires "
                f"both tdd_issue and at least one tdd_issue_<N>. "
                f"See CONTRIBUTING.md > TDD Issue Test Tags."
            )

    return None


def _should_invert_result(tags: set[str]) -> bool:
    """Return ``True`` if the test result should be inverted.

    A test result is inverted when ``tdd_expected_fail`` is present,
    indicating the test captures a known bug that has not yet been fixed.

    Args:
        tags: The tags on the test case (lowercased).
    """
    return "tdd_expected_fail" in tags


# ---------------------------------------------------------------------------
# Infrastructure error patterns — used by ``_is_infrastructure_error()``
# to detect non-assertion failures that should NOT be inverted.
#
# These patterns are checked against ``result.message`` (case-insensitive).
# Only clear infrastructure indicators are listed; unknown failure patterns
# are assumed to be assertion failures and ARE inverted (conservative
# approach matching the Behave ``apply_tdd_inversion()`` philosophy).
# ---------------------------------------------------------------------------
_INFRA_ERROR_PATTERNS: tuple[str, ...] = (
    # Robot keyword resolution errors
    "no keyword with name",
    # Python exception types from libraries
    "typeerror:",
    "importerror:",
    "modulenotfounderror:",
    "filenotfounderror:",
    "timeouterror:",
    "permissionerror:",
    "oserror:",
    "connectionerror:",
    "attributeerror:",
    # Network / connectivity errors
    # WARNING: these patterns can false-positive if a test asserts on
    # specific network error substrings in its expected output.
    "connection refused",
    "connection reset",
    "connection timed out",
    # Robot-specific infrastructure messages
    "no library with name",
    "importing library",
    "variable '${",  # RF scalar variable resolution error
    "variable '@{",  # RF list variable resolution error
    "variable '&{",  # RF dict variable resolution error
    "variable '%{",  # RF environment variable resolution error
)


def _has_setup_teardown_failure(result: ResultTestCase) -> bool:
    """Return ``True`` if the test failed due to setup or teardown.

    In Robot Framework, when a test setup fails the test body does not
    execute — the failure is infrastructure, not the captured bug.
    Similarly, a teardown failure indicates cleanup problems, not bug
    evidence. Note that Robot can report a teardown failure even when the
    body passed; the guard still returns ``True`` to avoid inverting a
    result that represents cleanup breakage rather than a fixed bug.

    Checks two indicators:

    1. ``result.setup.status`` or ``result.teardown.status`` is
       ``"FAIL"`` (structural check via the RF result model).
    2. ``result.message`` starts with ``"Setup failed:"`` or
       ``"Teardown failed:"`` (string check as a fallback for RF
       versions that may not populate the structural attributes).

    Args:
        result: The Robot Framework test result object.

    Returns:
        ``True`` if a setup or teardown failure was detected.
    """
    # Structural check: RF result model exposes setup/teardown objects.
    if result.setup and result.setup.status == "FAIL":
        return True
    if result.teardown and result.teardown.status == "FAIL":
        return True

    # String-based fallback: RF prefixes the message when setup/teardown
    # fails.
    msg = result.message or ""
    return msg.startswith("Setup failed:") or msg.startswith("Teardown failed:")


def _is_infrastructure_error(message: str) -> bool:
    """Return ``True`` if the failure message indicates an infrastructure error.

    Infrastructure errors (import failures, keyword resolution errors,
    timeouts, connection problems, etc.) are NOT evidence about the
    captured bug and should not be inverted by the TDD listener.

    The check is conservative: only well-known infrastructure patterns
    trigger a match.  Unknown failure messages are assumed to be
    legitimate assertion failures and will be inverted normally.

    Args:
        message: The ``result.message`` from the Robot test result.

    Returns:
        ``True`` if the message matches a known infrastructure error
        pattern.
    """
    if not message:
        return False

    lower_msg = message.lower()
    return any(pattern in lower_msg for pattern in _INFRA_ERROR_PATTERNS)


def start_test(
    data: RunningTestCase,
    result: ResultTestCase,
) -> None:
    """Validate TDD tags before the test runs.

    If validation fails, the error is stored for ``end_test`` to apply.
    The test is allowed to execute so that ``end_test`` can properly
    override the result.
    """
    tags = {str(t).lower() for t in data.tags}
    error = _validate_tdd_tags(tags)
    if error is not None:
        _validation_errors[result.full_name] = error


def end_test(
    data: RunningTestCase,
    result: ResultTestCase,
) -> None:
    """Invert test results for ``tdd_expected_fail`` tests and apply
    any tag validation errors.

    If there was a validation error from ``start_test``, the test is
    forced to fail with the validation error message regardless of
    the actual test outcome.

    For ``tdd_expected_fail`` tests with valid tags, three guards are
    checked before inversion (paralleling the Behave
    ``apply_tdd_inversion()`` guards):

    1. **Setup/teardown error guard** — if the test failed due to a
       setup or teardown error, inversion is skipped.
    2. **Non-assertion failure guard** — if ``result.message`` matches
       known infrastructure error patterns, inversion is skipped.
    3. **Dry-run guard** — if all body keywords have status ``NOT RUN``
       (dry-run mode), inversion is skipped.

    If no guard triggers:

    * If the test **failed** (bug still exists), the result is inverted
      to **PASS** (expected failure).
    * If the test **passed** (bug appears fixed), the result is inverted
      to **FAIL** with a guidance message.
    * If the test was **skipped** or has any other status, the result is
      left unchanged (a skipped test is not evidence of a fix).

    An idempotency guard prevents double-processing if the listener is
    loaded multiple times.
    """
    full_name = result.full_name

    # Idempotency guard — skip if this test was already processed by
    # a previous listener instance in the same process.
    if full_name in _processed_tests:
        return
    _processed_tests.add(full_name)

    # --- Handle validation errors first ---
    if full_name in _validation_errors:
        error_msg = _validation_errors.pop(full_name)
        result.status = "FAIL"
        result.message = f"TDD tag validation error: {error_msg}"
        _logger.error(
            "TDD tag validation error in '%s': %s",
            full_name,
            error_msg,
        )
        return

    # Tags are read from ``data.tags`` (static test definition) rather
    # than ``result.tags``.  TDD tags are declarative metadata and must
    # be present in the source file — runtime ``Set Tags`` / ``Remove
    # Tags`` modifications are not supported for TDD tags.
    tags = {str(t).lower() for t in data.tags}
    if not _should_invert_result(tags):
        return

    # --- Guard 1: Setup/teardown error ---
    # Never invert when the failure is from test setup or teardown.
    # The test body did not execute (or cleanup failed), so the result
    # is not evidence about the captured bug.
    if _has_setup_teardown_failure(result):
        message_snippet = (result.message or "")[:200]
        _logger.warning(
            "TDD expected-fail test '%s' has a setup/teardown failure "
            "— not inverting. Message: %s",
            full_name,
            message_snippet,
        )
        return

    # --- Guard 2: Non-assertion failure detection ---
    # If the test failed with a message matching known infrastructure
    # error patterns (import errors, keyword resolution failures,
    # timeouts, etc.), do not invert — the failure is not the captured
    # bug.
    if result.status == "FAIL" and _is_infrastructure_error(result.message):
        message_snippet = (result.message or "")[:200]
        _logger.warning(
            "TDD expected-fail test '%s' failed with an infrastructure "
            "error — not inverting. Message: %s",
            full_name,
            message_snippet,
        )
        return

    # --- Guard 3: Dry-run mode ---
    # In dry-run mode, Robot Framework validates keyword existence but
    # does not execute them.  The test status is PASS (no failure
    # occurred) but the body keywords have status NOT RUN.  Detect
    # dry-run by checking if ALL body keywords are NOT RUN — if so,
    # no test actually executed and the result is meaningless.
    if result.body and all(
        getattr(item, "status", None) == "NOT RUN" for item in result.body
    ):
        _logger.debug(
            "TDD expected-fail test '%s' appears to be in dry-run mode "
            "(all body keywords NOT RUN) — leaving unchanged.",
            full_name,
        )
        return

    if result.status == "FAIL":
        # Expected failure -- the bug still exists.  Invert to PASS.
        _logger.info(
            "TDD expected failure: test '%s' failed as expected "
            "(bug still exists). Inverting to PASS.",
            full_name,
        )
        result.status = "PASS"
        result.message = (
            "TDD expected failure: test failed as expected (bug still exists)."
        )
    elif result.status == "PASS":
        # Unexpected pass -- the bug appears to be fixed but the
        # tdd_expected_fail tag has not been removed.
        guidance = (
            "Bug appears to be fixed. Remove the tdd_expected_fail tag "
            "from this test and verify the fix through the bug fix "
            "workflow. See CONTRIBUTING.md > Bug Fix Workflow."
        )
        _logger.warning(
            "%s Test: '%s'",
            guidance,
            full_name,
        )
        result.status = "FAIL"
        result.message = guidance
    else:
        # SKIP or other statuses — leave unchanged.  A skipped
        # tdd_expected_fail test is not evidence of a fix; it simply
        # did not execute.
        _logger.debug(
            "TDD expected-fail test '%s' has status %s — leaving unchanged.",
            full_name,
            result.status,
        )


def close() -> None:
    """Clean up module-level state when the listener is unloaded.

    Called by Robot Framework when test execution finishes.  Clears
    internal state so the listener can be reused in long-lived processes
    or consecutive Robot runs without stale data.
    """
    _validation_errors.clear()
    _processed_tests.clear()
