"""
Service layer for test catalog, parameters, and reference ranges.
"""

import math
from uuid import UUID

from sqlalchemy.ext.asyncio import AsyncSession

from app.core.exceptions import AlreadyExistsException, NotFoundException
from app.core.pagination import PaginationParams
from app.core.responses import PaginatedResponse, PaginationMeta
from app.repositories.lab_repository import LabRepository
from app.repositories.test_repository import TestCatalogRepository
from app.schemas.test import (
    SeedCatalogRequest,
    TestCategoryCreate,
    TestCategoryResponse,
    TestCategoryUpdate,
    TestCreate,
    TestDetailResponse,
    TestParameterCreate,
    TestParameterRangeCreate,
    TestParameterRangeResponse,
    TestParameterRangeUpdate,
    TestParameterResponse,
    TestParameterUpdate,
    TestResponse,
    TestUpdate,
)


class TestCatalogService:
    """Business logic for test catalog administration."""

    def __init__(self, repo: TestCatalogRepository | None = None) -> None:
        self.repo = repo or TestCatalogRepository()

    # ── Categories ────────────────────────────────────────────────────────────

    async def list_categories(
        self,
        db: AsyncSession,
        pagination: PaginationParams,
        lab_id: UUID | None = None,
    ) -> PaginatedResponse[TestCategoryResponse]:
        categories, total = await self.repo.list_categories(
            db,
            limit=pagination.limit,
            offset=pagination.offset,
            lab_id=lab_id,
        )
        items = [TestCategoryResponse.model_validate(c) for c in categories]
        pages = math.ceil(total / pagination.limit) if total > 0 else 1
        return PaginatedResponse(
            data=items,
            meta=PaginationMeta(
                total=total,
                page=pagination.page,
                limit=pagination.limit,
                pages=pages,
            ),
        )

    async def get_category(self, db: AsyncSession, category_id: UUID) -> TestCategoryResponse:
        cat = await self.repo.get_category_by_id(db, category_id)
        if not cat:
            raise NotFoundException(message=f"Test category with id '{category_id}' was not found.")
        return TestCategoryResponse.model_validate(cat)

    async def create_category(self, db: AsyncSession, data: TestCategoryCreate) -> TestCategoryResponse:
        if data.lab_id is None:
            data.lab_id = await LabRepository().get_default_lab_id(db)

        existing = await self.repo.get_category_by_name(db, data.lab_id, data.name)
        if existing:
            raise AlreadyExistsException(
                message=f"A category with name '{data.name}' already exists in this lab."
            )

        archived = await self.repo.get_category_by_name_including_deleted(db, data.lab_id, data.name)
        if archived and archived.deleted_at is not None:
            cat = await self.repo.restore_category_from_create(db, archived, data)
            return TestCategoryResponse.model_validate(cat)

        cat = await self.repo.create_category(db, data)
        return TestCategoryResponse.model_validate(cat)

    async def update_category(
        self, db: AsyncSession, category_id: UUID, data: TestCategoryUpdate
    ) -> TestCategoryResponse:
        cat = await self.repo.get_category_by_id(db, category_id)
        if not cat:
            raise NotFoundException(message=f"Test category with id '{category_id}' was not found.")

        if data.name and data.name != cat.name:
            existing = await self.repo.get_category_by_name(db, cat.lab_id, data.name)
            if existing and existing.id != cat.id:
                raise AlreadyExistsException(
                    message=f"A category with name '{data.name}' already exists in this lab."
                )

        updated = await self.repo.update_category(db, cat, data)
        return TestCategoryResponse.model_validate(updated)

    async def delete_category(self, db: AsyncSession, category_id: UUID) -> None:
        cat = await self.repo.get_category_by_id(db, category_id)
        if not cat:
            raise NotFoundException(message=f"Test category with id '{category_id}' was not found.")
        # Cascade: remove panels in this category first so no orphan active
        # tests remain pointing at a deleted category. Reuses delete_test
        # semantics (hard delete when never ordered, else soft delete).
        tests, _ = await self.repo.list_tests(db, limit=1000, offset=0, category_id=category_id)
        for t in tests:
            order_count = await self.repo.get_test_order_count(db, t.id)
            if order_count == 0:
                await self.repo.hard_delete_test(db, t)
            else:
                await self.repo.delete_test(db, t)
        await self.repo.delete_category(db, cat)

    # ── Tests ─────────────────────────────────────────────────────────────────

    async def list_tests(
        self,
        db: AsyncSession,
        pagination: PaginationParams,
        lab_id: UUID | None = None,
        category_id: UUID | None = None,
        q: str | None = None,
    ) -> PaginatedResponse[TestResponse]:
        tests, total = await self.repo.list_tests(
            db,
            limit=pagination.limit,
            offset=pagination.offset,
            lab_id=lab_id,
            category_id=category_id,
            q=q,
        )
        items = []
        for t in tests:
            res = TestResponse.model_validate(t)
            if t.category:
                res.category_name = t.category.name
            items.append(res)
        pages = math.ceil(total / pagination.limit) if total > 0 else 1
        return PaginatedResponse(
            data=items,
            meta=PaginationMeta(
                total=total,
                page=pagination.page,
                limit=pagination.limit,
                pages=pages,
            ),
        )

    async def get_test(self, db: AsyncSession, test_id: UUID) -> TestDetailResponse:
        test = await self.repo.get_test_with_parameters(db, test_id)
        if not test:
            raise NotFoundException(message=f"Test with id '{test_id}' was not found.")
        params = [TestParameterResponse.model_validate(p) for p in test.parameters if p.deleted_at is None]
        return TestDetailResponse(
            id=test.id,
            lab_id=test.lab_id,
            category_id=test.category_id,
            name=test.name,
            code=test.code,
            description=test.description,
            price=test.price,
            result_type=test.result_type,
            is_active=test.is_active,
            created_at=test.created_at,
            updated_at=test.updated_at,
            parameters=params,
        )

    async def create_test(self, db: AsyncSession, data: TestCreate) -> TestResponse:
        if data.lab_id is None:
            data.lab_id = await LabRepository().get_default_lab_id(db)

        # Check category existence
        cat = await self.repo.get_category_by_id(db, data.category_id)
        if not cat:
            raise NotFoundException(message=f"Test category with id '{data.category_id}' was not found.")

        # Check code uniqueness — revive soft-deleted row with same code
        existing = await self.repo.get_test_by_code(db, data.lab_id, data.code)
        if existing:
            raise AlreadyExistsException(
                message=f"A test with code '{data.code}' already exists in this lab."
            )

        archived = await self.repo.get_test_by_code_including_deleted(db, data.lab_id, data.code)
        if archived and archived.deleted_at is not None:
            test = await self.repo.restore_test_from_create(db, archived, data)
            return TestResponse.model_validate(test)

        test = await self.repo.create_test(db, data)
        return TestResponse.model_validate(test)

    async def update_test(self, db: AsyncSession, test_id: UUID, data: TestUpdate) -> TestResponse:
        test = await self.repo.get_test_by_id(db, test_id)
        if not test:
            raise NotFoundException(message=f"Test with id '{test_id}' was not found.")

        if data.category_id is not None and data.category_id != test.category_id:
            cat = await self.repo.get_category_by_id(db, data.category_id)
            if not cat:
                raise NotFoundException(message=f"Test category with id '{data.category_id}' was not found.")

        if data.code is not None and data.code != test.code:
            existing = await self.repo.get_test_by_code(db, test.lab_id, data.code)
            if existing and existing.id != test.id:
                raise AlreadyExistsException(
                    message=f"A test with code '{data.code}' already exists in this lab."
                )

        updated = await self.repo.update_test(db, test, data)
        return TestResponse.model_validate(updated)

    async def delete_test(self, db: AsyncSession, test_id: UUID) -> None:
        test = await self.repo.get_test_by_id(db, test_id)
        if not test:
            raise NotFoundException(message=f"Test with id '{test_id}' was not found.")
        await self.repo.delete_test(db, test)

    # ── Parameters ────────────────────────────────────────────────────────────

    async def list_parameters(self, db: AsyncSession, test_id: UUID) -> list[TestParameterResponse]:
        test = await self.repo.get_test_by_id(db, test_id)
        if not test:
            raise NotFoundException(message=f"Test with id '{test_id}' was not found.")
        params = await self.repo.list_parameters(db, test_id)
        return [TestParameterResponse.model_validate(p) for p in params]

    async def get_parameter(self, db: AsyncSession, test_id: UUID, param_id: UUID) -> TestParameterResponse:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        return TestParameterResponse.model_validate(param)

    async def create_parameter(
        self, db: AsyncSession, test_id: UUID, data: TestParameterCreate
    ) -> TestParameterResponse:
        test = await self.repo.get_test_by_id(db, test_id)
        if not test:
            raise NotFoundException(message=f"Test with id '{test_id}' was not found.")

        param = await self.repo.create_parameter(db, test_id, data)
        return TestParameterResponse.model_validate(param)

    async def update_parameter(
        self, db: AsyncSession, test_id: UUID, param_id: UUID, data: TestParameterUpdate
    ) -> TestParameterResponse:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        updated = await self.repo.update_parameter(db, param, data)
        return TestParameterResponse.model_validate(updated)

    async def delete_parameter(self, db: AsyncSession, test_id: UUID, param_id: UUID) -> None:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        await self.repo.delete_parameter(db, param)

    # ── Reference Ranges ──────────────────────────────────────────────────────

    async def list_ranges(
        self, db: AsyncSession, test_id: UUID, param_id: UUID
    ) -> list[TestParameterRangeResponse]:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        ranges = await self.repo.list_ranges(db, param_id)
        return [TestParameterRangeResponse.model_validate(r) for r in ranges]

    async def get_range(
        self, db: AsyncSession, test_id: UUID, param_id: UUID, range_id: UUID
    ) -> TestParameterRangeResponse:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        rng = await self.repo.get_range_by_id(db, range_id)
        if not rng or rng.parameter_id != param_id:
            raise NotFoundException(
                message=f"Reference range with id '{range_id}' was not found for parameter '{param_id}'."
            )
        return TestParameterRangeResponse.model_validate(rng)

    async def create_range(
        self, db: AsyncSession, test_id: UUID, param_id: UUID, data: TestParameterRangeCreate
    ) -> TestParameterRangeResponse:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        rng = await self.repo.create_range(db, param_id, data)
        return TestParameterRangeResponse.model_validate(rng)

    async def update_range(
        self,
        db: AsyncSession,
        test_id: UUID,
        param_id: UUID,
        range_id: UUID,
        data: TestParameterRangeUpdate,
    ) -> TestParameterRangeResponse:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        rng = await self.repo.get_range_by_id(db, range_id)
        if not rng or rng.parameter_id != param_id:
            raise NotFoundException(
                message=f"Reference range with id '{range_id}' was not found for parameter '{param_id}'."
            )
        updated = await self.repo.update_range(db, rng, data)
        return TestParameterRangeResponse.model_validate(updated)

    async def delete_range(self, db: AsyncSession, test_id: UUID, param_id: UUID, range_id: UUID) -> None:
        param = await self.repo.get_parameter_by_id(db, param_id)
        if not param or param.test_id != test_id:
            raise NotFoundException(
                message=f"Parameter with id '{param_id}' was not found for test '{test_id}'."
            )
        rng = await self.repo.get_range_by_id(db, range_id)
        if not rng or rng.parameter_id != param_id:
            raise NotFoundException(
                message=f"Reference range with id '{range_id}' was not found for parameter '{param_id}'."
            )
        await self.repo.delete_range(db, rng)

    # ── Bulk Seed ─────────────────────────────────────────────────────────────

    async def seed_standard_catalog(self, db: AsyncSession, payload: SeedCatalogRequest) -> dict[str, int]:
        """Idempotent full-catalog upsert for POST /tests/seed.

        Matches by natural keys (category name → test code → parameter name →
        range bounds). Re-running changes nothing except filling gaps, so the
        frontend seeder completes in one request instead of hundreds.
        """
        lab_id = payload.lab_id or await LabRepository().get_default_lab_id(db)
        counts = {"categories": 0, "tests": 0, "updated": 0, "params": 0, "ranges": 0}

        for cat_in in payload.categories:
            cat = await self.repo.get_category_by_name(db, lab_id, cat_in.name)
            if not cat:
                archived = await self.repo.get_category_by_name_including_deleted(db, lab_id, cat_in.name)
                if archived is not None and archived.deleted_at is not None:
                    cat = await self.repo.restore_category_from_create(
                        db,
                        archived,
                        TestCategoryCreate(lab_id=lab_id, name=cat_in.name, description=cat_in.description),
                    )
                else:
                    cat = await self.repo.create_category(
                        db,
                        TestCategoryCreate(lab_id=lab_id, name=cat_in.name, description=cat_in.description),
                    )
                counts["categories"] += 1

            for t in cat_in.tests:
                test = await self.repo.get_test_by_code(db, lab_id, t.code)
                if not test:
                    archived_t = await self.repo.get_test_by_code_including_deleted(db, lab_id, t.code)
                    if archived_t is not None and archived_t.deleted_at is not None:
                        test = await self.repo.restore_test_from_create(
                            db,
                            archived_t,
                            TestCreate(
                                lab_id=lab_id,
                                category_id=cat.id,
                                name=t.name,
                                code=t.code,
                                description=t.description,
                                price=t.price,
                                result_type=t.result_type,
                            ),
                        )
                    else:
                        test = await self.repo.create_test(
                            db,
                            TestCreate(
                                lab_id=lab_id,
                                category_id=cat.id,
                                name=t.name,
                                code=t.code,
                                description=t.description,
                                price=t.price,
                                result_type=t.result_type,
                            ),
                        )
                    counts["tests"] += 1
                elif (
                    test.name != t.name
                    or test.price != t.price
                    or test.category_id != cat.id
                    or test.result_type != t.result_type
                ):
                    test = await self.repo.update_test(
                        db,
                        test,
                        TestUpdate(
                            category_id=cat.id,
                            name=t.name,
                            price=t.price,
                            result_type=t.result_type,
                            description=t.description,
                        ),
                    )
                    counts["updated"] += 1

                existing_params = {p.name.lower(): p for p in await self.repo.list_parameters(db, test.id)}
                for order, p in enumerate(t.parameters):
                    param = existing_params.get(p.name.lower())
                    if not param:
                        param = await self.repo.create_parameter(
                            db,
                            test.id,
                            TestParameterCreate(name=p.name, unit=p.unit, price=p.price, sort_order=order),
                        )
                        counts["params"] += 1
                    elif p.price is not None and param.price != p.price:
                        param = await self.repo.update_parameter(
                            db, param, TestParameterUpdate(price=p.price)
                        )

                    if not p.ranges:
                        continue
                    stored = await self.repo.list_ranges(db, param.id)
                    for r in p.ranges:
                        if r.numeric_low is None and r.numeric_high is None and not r.text_normal:
                            continue
                        dup = any(
                            (
                                (s.gender is None and r.gender is None)
                                or (
                                    s.gender is not None
                                    and r.gender is not None
                                    and str(s.gender) == str(r.gender)
                                )
                            )
                            and (
                                s.numeric_low is None
                                and r.numeric_low is None
                                or s.numeric_low is not None
                                and r.numeric_low is not None
                                and float(s.numeric_low) == float(r.numeric_low)
                            )
                            and (
                                s.numeric_high is None
                                and r.numeric_high is None
                                or s.numeric_high is not None
                                and r.numeric_high is not None
                                and float(s.numeric_high) == float(r.numeric_high)
                            )
                            and (s.text_normal or None) == r.text_normal
                            for s in stored
                        )
                        if dup:
                            continue
                        created = await self.repo.create_range(
                            db,
                            param.id,
                            TestParameterRangeCreate(
                                gender=r.gender,
                                age_min=r.age_min,
                                age_max=r.age_max,
                                numeric_low=r.numeric_low,
                                numeric_high=r.numeric_high,
                                text_normal=r.text_normal,
                            ),
                        )
                        stored.append(created)
                        counts["ranges"] += 1

        return counts
