from __future__ import annotations

import re
from typing import Any

from app.application.observability.service import ObservabilityService
from app.domain.mappers import to_experiment, to_release, to_site, to_variant
from app.domain.observability import SystemEvent
from app.domain.runtime import RuntimeExperimentContext, RuntimeVariantCandidate, choose_variant
from app.repositories.directus.experiments import ExperimentRepository
from app.repositories.directus.releases import ReleaseRepository
from app.repositories.directus.sites import SiteRepository
from app.repositories.directus.variants import VariantRepository
from app.schemas.runtime import (
    RuntimeAssignRequest,
    RuntimeAssignResponse,
    RuntimeBootstrapRequest,
    RuntimeBootstrapResponse,
    RuntimeExperimentInput,
    RuntimeEventRequest,
    RuntimeEventResponse,
    RuntimePayloadRequest,
    RuntimePayloadResponse,
    RuntimeVariantCandidateInput,
)


class RuntimeService:
    """First-pass runtime orchestration service.

    The current version focuses on:
    - deterministic assignment rules
    - stable API contracts for the SDK
    - keeping runtime logic out of the frontend

    Later we can swap the input source from request payloads to repositories
    without changing the external response shapes.
    """

    def __init__(
        self,
        observability: ObservabilityService | None = None,
        site_repository: SiteRepository | None = None,
        experiment_repository: ExperimentRepository | None = None,
        variant_repository: VariantRepository | None = None,
        release_repository: ReleaseRepository | None = None,
    ) -> None:
        self.observability = observability or ObservabilityService()
        self.site_repository = site_repository or SiteRepository()
        self.experiment_repository = experiment_repository or ExperimentRepository()
        self.variant_repository = variant_repository or VariantRepository()
        self.release_repository = release_repository or ReleaseRepository()

    async def bootstrap(self, request: RuntimeBootstrapRequest) -> RuntimeBootstrapResponse:
        site = await self._resolve_site(request.site_id, request.site_key)
        if not site:
            return RuntimeBootstrapResponse(
                site_id=request.site_id,
                site_key=request.site_key,
                url=request.url,
                visitor_id=request.visitor_id,
                candidate_experiments=[],
            )

        experiments = await self.experiment_repository.list(
            params={
                "filter[site_id][_eq]": site.id,
                "filter[status][_eq]": "running",
            }
        )

        candidate_experiments = []
        for raw_experiment in experiments:
            experiment = to_experiment(raw_experiment)
            if not self._matches_targeting(request.url, request.user_agent, experiment.targeting_config):
                continue

            raw_releases = await self.release_repository.list(
                params={
                    "filter[experiment_id][_eq]": experiment.id,
                    "filter[status][_eq]": "published",
                    "sort[]": "-version_no",
                    "limit": 1,
                }
            )
            release = to_release(raw_releases[0]) if raw_releases else None

            # Build a traffic weight index from the release snapshot when available.
            # This ensures assignment weights are stable and match what was published,
            # even if variant weights have since been edited.
            snapshot_weight: dict[str, int] = {}
            snapshot_key: dict[str, str] = {}
            if release and isinstance(release.runtime_payload, dict):
                for sv in release.runtime_payload.get("variants", []):
                    vid = str(sv.get("variant_id", ""))
                    if vid:
                        snapshot_weight[vid] = int(sv.get("traffic_weight", 0))
                        snapshot_key[vid] = str(sv.get("variant_key", ""))

            raw_variants = await self.variant_repository.list(
                params={"filter[experiment_id][_eq]": experiment.id}
            )
            variants = [to_variant(item) for item in raw_variants]
            if not variants:
                continue

            candidate_experiments.append(
                RuntimeExperimentInput(
                    experiment_id=experiment.id,
                    experiment_key=experiment.experiment_key,
                    status=experiment.status,
                    site_key=site.site_key,
                    release_id=release.id if release else None,
                    release_version=release.version_no if release else None,
                    payload=release.runtime_payload if release else None,
                    variants=[
                        RuntimeVariantCandidateInput(
                            id=variant.id,
                            variant_key=snapshot_key.get(variant.id, variant.variant_key),
                            traffic_weight=snapshot_weight.get(variant.id, variant.traffic_weight),
                            content_config=variant.content_config,
                        )
                        for variant in variants
                    ],
                )
            )

        return RuntimeBootstrapResponse(
            site_id=site.id,
            site_key=site.site_key,
            url=request.url,
            visitor_id=request.visitor_id,
            candidate_experiments=candidate_experiments,
        )

    async def assign(self, request: RuntimeAssignRequest) -> RuntimeAssignResponse | None:
        experiment = RuntimeExperimentContext(
            experiment_id=request.experiment.experiment_id,
            experiment_key=request.experiment.experiment_key,
            status=request.experiment.status,
            site_key=request.experiment.site_key,
            assignment_salt=request.experiment.assignment_salt,
            release_id=request.experiment.release_id,
            release_version=request.experiment.release_version,
            payload=request.experiment.payload,
            variants=[
                RuntimeVariantCandidate(
                    id=variant.id,
                    variant_key=variant.variant_key,
                    traffic_weight=variant.traffic_weight,
                    content_config=variant.content_config,
                )
                for variant in request.experiment.variants
            ],
        )

        decision = choose_variant(
            experiment_id=experiment.experiment_id,
            experiment_key=experiment.experiment_key,
            visitor_id=request.visitor_id,
            site_key=experiment.site_key,
            assignment_salt=experiment.assignment_salt,
            variants=experiment.variants,
        )
        if not decision:
            return None

        await self.observability.record_system_event(
            SystemEvent(
                category="runtime_assignment",
                event_name="assignment_decided",
                experiment_id=decision.experiment_id,
                experiment_key=decision.experiment_key,
                variant_id=decision.variant_id,
                variant_key=decision.variant_key,
                visitor_id=request.visitor_id,
                payload={"bucket": decision.bucket, "reason": decision.reason},
            )
        )

        return RuntimeAssignResponse(
            experiment_id=decision.experiment_id,
            experiment_key=decision.experiment_key,
            variant_id=decision.variant_id,
            variant_key=decision.variant_key,
            bucket=decision.bucket,
            reason=decision.reason,
        )

    async def payload(self, request: RuntimePayloadRequest) -> RuntimePayloadResponse:
        assignment = await self.assign(
            RuntimeAssignRequest(
                visitor_id=request.visitor_id,
                experiment=request.experiment,
            )
        )

        return RuntimePayloadResponse(
            experiment_id=request.experiment.experiment_id,
            experiment_key=request.experiment.experiment_key,
            release_id=request.experiment.release_id,
            release_version=request.experiment.release_version,
            assigned_variant_id=assignment.variant_id if assignment else None,
            assigned_variant_key=assignment.variant_key if assignment else None,
            payload=request.experiment.payload,
        )

    async def ingest_event(self, request: RuntimeEventRequest) -> RuntimeEventResponse:
        # Event forwarding/queueing will be introduced when GA4/GTM ingest and
        # audit storage are wired in. We preserve the external contract now.
        await self.observability.record_system_event(
            SystemEvent(
                category="runtime_event",
                event_name=request.event_name,
                site_id=request.site_id,
                site_key=request.site_key,
                experiment_id=request.experiment_id,
                experiment_key=request.experiment_key,
                variant_id=request.variant_id,
                variant_key=request.variant_key,
                visitor_id=request.visitor_id,
                payload=request.payload,
            )
        )
        return RuntimeEventResponse(
            accepted=True,
            event_name=request.event_name,
        )

    async def _resolve_site(self, site_id: str | None, site_key: str | None):
        if site_id:
            item = await self.site_repository.get(site_id)
            if item:
                return to_site(item)

        if site_key:
            items = await self.site_repository.list(
                params={"filter[site_key][_eq]": site_key}
            )
            if items:
                return to_site(items[0])

        return None

    def _matches_targeting(
        self,
        request_url: str,
        user_agent: str | None,
        targeting_config: dict[str, Any] | list[Any] | None,
    ) -> bool:
        if not isinstance(targeting_config, dict):
            return True

        # Device targeting — empty list = all devices
        device_targets = targeting_config.get("device_targets")
        if device_targets:
            detected = self._detect_device(user_agent or "")
            if detected not in device_targets:
                return False

        # URL rules (all must match — AND logic)
        url_rules = targeting_config.get("url_rules")
        if url_rules:
            return all(self._matches_url_rule(request_url, rule) for rule in url_rules)

        # Fallback: base_url prefix check
        base_url = targeting_config.get("base_url")
        if base_url:
            return str(request_url).startswith(str(base_url))

        return True

    def _detect_device(self, user_agent: str) -> str:
        ua = user_agent.lower()
        if "ipad" in ua or ("android" in ua and "mobile" not in ua):
            return "tablet"
        if "mobile" in ua or "iphone" in ua or "android" in ua:
            return "mobile"
        return "desktop"

    def _matches_url_rule(self, url: str, rule: dict[str, Any]) -> bool:
        operator = rule.get("operator", "contains")
        value = str(rule.get("value", ""))
        if not value:
            return True
        if operator == "contains":
            return value in url
        if operator == "equals":
            return url == value
        if operator == "starts_with":
            return url.startswith(value)
        if operator == "regex":
            try:
                return bool(re.search(value, url))
            except re.error:
                return False
        return True
