"""
Nexa Language — Testing Matchers (Phase 7 / v27)
Rich expect(x).to_equal(5) style assertions

Usage in .nexa test blocks:
    test "user data" {
        expect(user.name).to_equal("Alice")
        expect(scores).to_contain(95)
        expect(response.status).to_be_in_range(200, 299)
        expect(model.accuracy).to_be_greater_than(0.9)
        expect(fn).to_throw("NullError")
    }
"""
from __future__ import annotations
from typing import Any, Callable, Optional, Type


class NexaExpectError(AssertionError):
    """Raised when an expect() assertion fails."""
    pass


class Expectation:
    """Fluent assertion chain — expect(value).to_equal(...)"""

    def __init__(self, subject: Any, label: str = ""):
        self._subject = subject
        self._label   = label or repr(subject)
        self._negated = False

    @property
    def not_(self) -> "Expectation":
        """Negate the next matcher. expect(x).not_.to_equal(5)"""
        self._negated = True
        return self

    def _check(self, passed: bool, msg: str):
        if self._negated:
            passed = not passed
            neg_label = "not "
        else:
            neg_label = ""
        if not passed:
            raise NexaExpectError(f"  expect({self._label}) {neg_label}{msg}")
        return self

    # ── Equality ─────────────────────────────────────────────────────────────
    def to_equal(self, expected: Any) -> "Expectation":
        return self._check(self._subject == expected,
                           f"to_equal({expected!r}) but got {self._subject!r}")

    def to_be(self, expected: Any) -> "Expectation":
        """Identity check (is)."""
        return self._check(self._subject is expected,
                           f"to_be({expected!r}) (identity)")

    # ── Truthiness ────────────────────────────────────────────────────────────
    def to_be_truthy(self) -> "Expectation":
        return self._check(bool(self._subject), "to_be_truthy()")

    def to_be_falsy(self) -> "Expectation":
        return self._check(not bool(self._subject), "to_be_falsy()")

    def to_be_null(self) -> "Expectation":
        return self._check(self._subject is None, "to_be_null()")

    # ── Numeric ──────────────────────────────────────────────────────────────
    def to_be_greater_than(self, n: Any) -> "Expectation":
        return self._check(self._subject > n,
                           f"to_be_greater_than({n}) but got {self._subject}")

    def to_be_less_than(self, n: Any) -> "Expectation":
        return self._check(self._subject < n,
                           f"to_be_less_than({n}) but got {self._subject}")

    def to_be_in_range(self, lo: Any, hi: Any) -> "Expectation":
        return self._check(lo <= self._subject <= hi,
                           f"to_be_in_range({lo}, {hi}) but got {self._subject}")

    def to_be_close_to(self, expected: float, delta: float = 1e-6) -> "Expectation":
        return self._check(abs(self._subject - expected) <= delta,
                           f"to_be_close_to({expected}, delta={delta}) but got {self._subject}")

    # ── Strings ───────────────────────────────────────────────────────────────
    def to_start_with(self, prefix: str) -> "Expectation":
        return self._check(str(self._subject).startswith(prefix),
                           f"to_start_with({prefix!r}) but got {self._subject!r}")

    def to_end_with(self, suffix: str) -> "Expectation":
        return self._check(str(self._subject).endswith(suffix),
                           f"to_end_with({suffix!r}) but got {self._subject!r}")

    def to_contain(self, item: Any) -> "Expectation":
        try:
            ok = item in self._subject
        except TypeError:
            ok = False
        return self._check(ok, f"to_contain({item!r}) in {self._subject!r}")

    def to_match(self, pattern: str) -> "Expectation":
        import re
        return self._check(bool(re.search(pattern, str(self._subject))),
                           f"to_match(/{pattern}/) but got {self._subject!r}")

    # ── Collections ───────────────────────────────────────────────────────────
    def to_have_length(self, n: int) -> "Expectation":
        return self._check(len(self._subject) == n,
                           f"to_have_length({n}) but got len={len(self._subject)}")

    def to_be_empty(self) -> "Expectation":
        return self._check(len(self._subject) == 0,
                           f"to_be_empty() but got len={len(self._subject)}")

    def to_include(self, key: Any) -> "Expectation":
        """dict.to_include(key)"""
        return self._check(key in self._subject,
                           f"to_include({key!r}) in {self._subject!r}")

    # ── Type ──────────────────────────────────────────────────────────────────
    def to_be_type(self, type_name: str) -> "Expectation":
        actual = type(self._subject).__name__
        return self._check(actual == type_name,
                           f"to_be_type({type_name!r}) but got {actual!r}")

    def to_be_instance_of(self, cls: Type) -> "Expectation":
        return self._check(isinstance(self._subject, cls),
                           f"to_be_instance_of({cls.__name__}) but got {type(self._subject).__name__}")

    # ── Exceptions ────────────────────────────────────────────────────────────
    def to_throw(self, error_msg_contains: Optional[str] = None,
                  error_type: Optional[str] = None) -> "Expectation":
        if not callable(self._subject):
            raise NexaExpectError(f"  expect(): subject must be callable for to_throw()")
        try:
            self._subject()
        except Exception as e:
            # Got an exception — check it matches expectations
            if error_type and type(e).__name__ != error_type:
                return self._check(False,
                                   f"to_throw({error_type!r}) but got {type(e).__name__!r}")
            if error_msg_contains and error_msg_contains not in str(e):
                return self._check(False,
                                   f"to_throw containing {error_msg_contains!r} but got {str(e)!r}")
            return self._check(not self._negated, "to_throw() — exception raised as expected")
        return self._check(self._negated, "to_throw() — no exception was raised")

    # ── ML-specific matchers ──────────────────────────────────────────────────
    def to_have_accuracy_above(self, threshold: float) -> "Expectation":
        """ML: expect(model.accuracy).to_have_accuracy_above(0.9)"""
        acc = self._subject if isinstance(self._subject, (int, float)) else getattr(self._subject, "accuracy", None)
        if acc is None:
            raise NexaExpectError(f"  expect(): subject has no 'accuracy' attribute for to_have_accuracy_above()")
        return self._check(acc > threshold,
                           f"to_have_accuracy_above({threshold}) but got {acc:.4f}")

    def to_converge_within(self, epochs: int, min_accuracy: float = 0.9) -> "Expectation":
        """ML: expect(training_history).to_converge_within(50, min_accuracy=0.9)"""
        history = self._subject
        if isinstance(history, list):
            reached = any(v >= min_accuracy for v in history[:epochs])
        elif hasattr(history, "accuracy"):
            reached = history.accuracy >= min_accuracy
        else:
            reached = False
        return self._check(reached,
                           f"to_converge_within({epochs} epochs, accuracy>={min_accuracy})")


def expect(subject: Any, label: str = "") -> Expectation:
    """Entry point: expect(x).to_equal(5)"""
    return Expectation(subject, label)


# ── EXPORTS (for interpreter BUILTINS) ────────────────────────────────────────
MATCHERS_EXPORTS = {
    "expect":         expect,
    "NexaExpectError": NexaExpectError,
}
