"""
Test catalog models.

Tables in this file:
  test_category       — groups tests by discipline (Hematology, Biochemistry, etc.)
  test               — individual diagnostic tests with pricing
  test_parameter     — parameters/sub-tests within a test (e.g., CBC has RBC, WBC, HGB)
  test_parameter_range — normal reference ranges per parameter (gender/age-based)

Relationships
─────────────
  TestCategory  1──N  Test
  Test          1──N  TestParameter
  TestParameter 1──N  TestParameterRange

Result Type
───────────
A Test has a result_type that determines how results are captured:
  numeric   — single numeric value with unit and reference range
  text      — free text (e.g., "Positive", "Negative", "Reactive")
  composite — contains multiple TestParameter entries, each with their
              own value, unit, and reference range

Soft Delete Decisions
─────────────────────
  TestCategory       → YES: deactivation preferred over deletion (tests remain)
  Test               → YES: test history must be preserved (ordered, billed, reported)
  TestParameter      → YES: results may reference parameters; can't hard-delete
  TestParameterRange → NO:  replace by deleting the old range and inserting the new one

Lab Scoping
───────────
  TestCategory       → YES (LabScopedMixin) — catalog is per-branch
  Test               → YES (LabScopedMixin) — pricing can vary per branch
  TestParameter      → NO  — scoped indirectly via test_id → lab_id
  TestParameterRange → NO  — scoped indirectly via parameter_id → test_id → lab_id
"""

import enum
import uuid
from decimal import Decimal

import sqlalchemy as sa
from sqlalchemy import (
    Boolean,
    ForeignKey,
    Integer,
    Numeric,
    String,
    Text,
    UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.base import (
    Base,
    LabScopedMixin,
    SoftDeleteMixin,
    TimestampMixin,
    UUIDPrimaryKeyMixin,
)
from app.models.patient import Gender, GenderType

# ── Enums ──────────────────────────────────────────────────────────────────────


class TestResultType(enum.StrEnum):
    """
    How a test's result is captured and displayed.

    numeric   — a single value with unit and reference range
                (e.g., Creatinine: 0.9 mg/dL, normal 0.6–1.2)
    text      — free text entry
                (e.g., Urine Appearance: "Clear", Culture: "No growth")
    composite — has multiple TestParameter rows, each with their own
                value and reference range (e.g., CBC, LFT, KFT)
    """

    NUMERIC = "numeric"
    TEXT = "text"
    COMPOSITE = "composite"


TestResultTypeType = sa.Enum(
    TestResultType,
    name="test_result_type",
    native_enum=True,
    create_constraint=True,
    values_callable=lambda obj: [e.value for e in obj],
)


class CategoryModality(enum.StrEnum):
    """
    Clinical discipline routed to a work queue.

    pathology — specimen-based lab panels (Hematology, Biochemistry, ...).
                Visible in the pathologist verification queue.
    imaging   — narrative radiology work (X-ray, Ultrasound, ...).
                Visible in the radiologist imaging queue; needs no specimen.
    """

    PATHOLOGY = "pathology"
    IMAGING = "imaging"


CategoryModalityType = sa.Enum(
    CategoryModality,
    name="category_modality",
    native_enum=True,
    create_constraint=True,
    values_callable=lambda obj: [e.value for e in obj],
)


# ── Models ─────────────────────────────────────────────────────────────────────


class TestCategory(UUIDPrimaryKeyMixin, TimestampMixin, SoftDeleteMixin, LabScopedMixin, Base):
    """
    Discipline-level grouping for tests and packages.

    Examples: Hematology, Biochemistry, Microbiology, Radiology, Serology.

    Soft delete: YES — deactivated categories hide their tests from new orders
                       while preserving history.
    """

    __tablename__ = "test_category"
    __table_args__ = (UniqueConstraint("lab_id", "name", name="uq_test_category_lab_id_name"),)

    name: Mapped[str] = mapped_column(
        String(200),
        nullable=False,
        index=True,
        comment="Category display name (e.g., 'Hematology')",
    )
    description: Mapped[str | None] = mapped_column(
        Text,
        nullable=True,
        comment="Optional description shown in the catalog UI",
    )
    modality: Mapped[CategoryModality] = mapped_column(
        CategoryModalityType,
        nullable=False,
        default=CategoryModality.PATHOLOGY,
        server_default="pathology",
        comment="Work queue routing: pathology (specimen lab) or imaging (radiology narrative)",
    )
    is_active: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        default=True,
        server_default="true",
        comment="False = hidden from new order creation",
    )

    # Relationships
    lab: Mapped["Lab"] = relationship(  # type: ignore[name-defined]
        "Lab",
        foreign_keys="[TestCategory.lab_id]",
        lazy="selectin",
    )
    tests: Mapped[list["Test"]] = relationship(
        back_populates="category",
        lazy="noload",
    )

    def __repr__(self) -> str:
        return f"<TestCategory id={self.id!s:.8} name={self.name!r}>"


class Test(UUIDPrimaryKeyMixin, TimestampMixin, SoftDeleteMixin, LabScopedMixin, Base):
    """
    An individual diagnostic test offered by the lab.

    Each test has:
      - A category (for grouping in the catalog and on reports)
      - A price (captured at order time to handle future price changes)
      - A result_type that drives how results are entered

    IMPORTANT — price capture:
      When a test is added to an order, the current price is copied into
      order_test.price_at_order. Subsequent price changes do not affect
      existing orders.

    Soft delete: YES — a test may be discontinued but its history (order records,
                        reports, bills) must be preserved.
    """

    __tablename__ = "test"
    __table_args__ = (UniqueConstraint("lab_id", "code", name="uq_test_lab_id_code"),)

    category_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("test_category.id", ondelete="RESTRICT"),
        nullable=False,
        index=True,
        comment="FK → test_category.id",
    )
    name: Mapped[str] = mapped_column(
        String(200),
        nullable=False,
        index=True,
        comment="Full test name (e.g., 'Complete Blood Count')",
    )
    code: Mapped[str] = mapped_column(
        String(50),
        nullable=False,
        index=True,
        comment="Short code used in orders and on reports (e.g., 'CBC', 'LFT')",
    )
    description: Mapped[str | None] = mapped_column(
        Text,
        nullable=True,
        comment="Optional clinical description or note",
    )
    price: Mapped[Decimal] = mapped_column(
        Numeric(10, 2),
        nullable=False,
        comment="Current price for this test — copied to order_test.price_at_order on booking",
    )
    result_type: Mapped[TestResultType] = mapped_column(
        TestResultTypeType,
        nullable=False,
        comment="How this test's result is captured (numeric / text / composite)",
    )
    is_active: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        default=True,
        server_default="true",
        comment="False = hidden from new order creation",
    )

    # Relationships
    lab: Mapped["Lab"] = relationship(  # type: ignore[name-defined]
        "Lab",
        foreign_keys="[Test.lab_id]",
        lazy="selectin",
    )
    category: Mapped["TestCategory"] = relationship(
        back_populates="tests",
        lazy="selectin",
    )
    parameters: Mapped[list["TestParameter"]] = relationship(
        back_populates="test",
        lazy="noload",
        cascade="all, delete-orphan",
        order_by="TestParameter.sort_order",
    )
    package_items: Mapped[list["TestPackageItem"]] = relationship(  # type: ignore[name-defined]
        back_populates="test",
        lazy="noload",
    )

    def __repr__(self) -> str:
        return f"<Test id={self.id!s:.8} code={self.code!r} name={self.name!r}>"


class TestParameter(UUIDPrimaryKeyMixin, TimestampMixin, SoftDeleteMixin, Base):
    """
    A measurable sub-component within a test.

    For COMPOSITE tests, each parameter represents one row on the report:
      CBC → [RBC, WBC, HGB, HCT, PLT, MCV, MCH, MCHC, ...]
      LFT → [ALT, AST, ALP, GGT, Total Bilirubin, ...]

    For NUMERIC tests, there is typically one parameter (the test itself).
    For TEXT tests, parameters may be omitted or used for structured output.

    calc_factor: optional multiplier applied to the raw instrument reading
                 to produce the reported value. If null, raw value is used.

    sort_order: controls the display order on reports (lower = first).

    Soft delete: YES — existing results reference parameters; hard delete
                        would orphan historical result data.
    Lab scoping: NO  — scoped via test_id → lab_id.
    """

    __tablename__ = "test_parameter"

    test_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("test.id", ondelete="RESTRICT"),
        nullable=False,
        index=True,
        comment="FK → test.id — the test this parameter belongs to",
    )
    name: Mapped[str] = mapped_column(
        String(200),
        nullable=False,
        comment="Parameter name shown on the report (e.g., 'Haemoglobin')",
    )
    unit: Mapped[str | None] = mapped_column(
        String(50),
        nullable=True,
        comment="Measurement unit (e.g., 'mg/dL', 'g/L', 'cells/μL')",
    )
    price: Mapped[Decimal | None] = mapped_column(
        Numeric(10, 2),
        nullable=True,
        default=None,
        comment="Individual price for this analyte if ordered separately",
    )
    decimal_places: Mapped[int] = mapped_column(
        Integer,
        nullable=False,
        default=2,
        server_default="2",
        comment="Number of decimal places for display formatting",
    )
    calc_factor: Mapped[Decimal | None] = mapped_column(
        Numeric(10, 6),
        nullable=True,
        comment="Optional multiplier applied to raw instrument reading before storing",
    )
    method_note: Mapped[str | None] = mapped_column(
        Text,
        nullable=True,
        comment="Analytical method or instrument note printed on the report",
    )
    sort_order: Mapped[int] = mapped_column(
        Integer,
        nullable=False,
        default=0,
        server_default="0",
        index=True,
        comment="Display ordering within the test (ascending)",
    )
    is_active: Mapped[bool] = mapped_column(
        Boolean,
        nullable=False,
        default=True,
        server_default="true",
        comment="False = parameter hidden from result entry and reports",
    )

    # Relationships
    test: Mapped["Test"] = relationship(
        back_populates="parameters",
        lazy="selectin",
    )
    ranges: Mapped[list["TestParameterRange"]] = relationship(
        back_populates="parameter",
        lazy="noload",
        cascade="all, delete-orphan",
    )

    def __repr__(self) -> str:
        return f"<TestParameter id={self.id!s:.8} name={self.name!r} unit={self.unit!r}>"


class TestParameterRange(UUIDPrimaryKeyMixin, TimestampMixin, Base):
    """
    Normal reference range for a test parameter, segmented by gender and age.

    Multiple ranges can exist for one parameter — the applicable range is
    selected at report generation based on the patient's gender and age.

    Range selection logic:
      1. Match on gender (exact) or gender IS NULL (applies to all genders)
      2. Match on age_min <= patient_age <= age_max
         (NULL age_min = no lower bound; NULL age_max = no upper bound)
      3. If multiple ranges match, take the most specific one

    numeric_low / numeric_high: the normal range bounds.
      Values outside this range are flagged as abnormal on the report.
      Either can be NULL if there is only a one-sided bound.

    text_normal: for TEXT-type parameters, the expected normal value string
      (e.g., "Negative", "Not detected"). Used for comparison on reports.

    Soft delete: NO — update ranges by deleting old rows and inserting new ones.
                      (No historical results reference this table directly —
                       ranges are resolved at report generation time.)
    Lab scoping: NO — scoped via parameter_id → test_id → lab_id.
    """

    __tablename__ = "test_parameter_range"

    parameter_id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True),
        ForeignKey("test_parameter.id", ondelete="CASCADE"),
        nullable=False,
        index=True,
        comment="FK → test_parameter.id",
    )
    gender: Mapped[Gender | None] = mapped_column(
        GenderType,
        nullable=True,
        comment="Gender this range applies to. NULL = applies to all genders.",
    )
    age_min: Mapped[int | None] = mapped_column(
        Integer,
        nullable=True,
        comment="Minimum age in years (inclusive). NULL = no lower age bound.",
    )
    age_max: Mapped[int | None] = mapped_column(
        Integer,
        nullable=True,
        comment="Maximum age in years (inclusive). NULL = no upper age bound.",
    )
    numeric_low: Mapped[Decimal | None] = mapped_column(
        Numeric(10, 4),
        nullable=True,
        comment="Lower bound of normal range. NULL = no lower bound.",
    )
    numeric_high: Mapped[Decimal | None] = mapped_column(
        Numeric(10, 4),
        nullable=True,
        comment="Upper bound of normal range. NULL = no upper bound.",
    )
    text_normal: Mapped[str | None] = mapped_column(
        String(200),
        nullable=True,
        comment="Expected normal value for TEXT-type parameters (e.g., 'Negative')",
    )

    # Relationships
    parameter: Mapped["TestParameter"] = relationship(
        back_populates="ranges",
        lazy="selectin",
    )

    def __repr__(self) -> str:
        return (
            f"<TestParameterRange parameter_id={self.parameter_id!s:.8} "
            f"gender={self.gender} "
            f"age={self.age_min}–{self.age_max} "
            f"range={self.numeric_low}–{self.numeric_high}>"
        )
