from __future__ import annotations

import asyncio
import uuid
from datetime import datetime
from typing import Any, Literal, Protocol, runtime_checkable

import sqlalchemy
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator

from ..entity.persistence import model as persistence_model


LANGBOT_MODELS_PROVIDER_REQUESTER = 'space-chat-completions'
LANGBOT_MODELS_PROVIDER_NAME = 'LangBot Models'
_MODEL_RESOURCE_NAMESPACE = uuid.UUID('94c703ca-1df5-4e91-bcd3-74ac65cb7921')
_SUPPORTED_CATEGORIES = {'chat', 'embedding', 'rerank'}
_MODEL_TABLES = (
    persistence_model.LLMModel,
    persistence_model.EmbeddingModel,
    persistence_model.RerankModel,
)


class CloudModelCatalogItem(BaseModel):
    model_config = ConfigDict(extra='forbid', frozen=True)

    uuid: str = Field(min_length=1, max_length=255)
    model_id: str = Field(min_length=1, max_length=255)
    category: Literal['chat', 'embedding', 'rerank']
    llm_abilities: tuple[str, ...] = ()
    is_featured: bool = False
    featured_order: int = 0

    @field_validator('llm_abilities', mode='before')
    @classmethod
    def normalize_missing_abilities(cls, value: Any) -> Any:
        return () if value is None else value

    @field_validator('llm_abilities')
    @classmethod
    def validate_abilities(cls, value: tuple[str, ...]) -> tuple[str, ...]:
        if any(not item.strip() or len(item) > 64 for item in value):
            raise ValueError('Model abilities must be non-empty strings of at most 64 characters')
        if len(set(value)) != len(value):
            raise ValueError('Model abilities must be unique')
        return value


class CloudWorkspaceModelBilling(BaseModel):
    model_config = ConfigDict(extra='forbid', frozen=True)

    workspace_uuid: str = Field(min_length=36, max_length=36)
    owner_account_uuid: str | None = Field(default=None, min_length=36, max_length=36)
    api_key: SecretStr | None = None
    credits: int | None = None

    @field_validator('workspace_uuid')
    @classmethod
    def validate_uuid(cls, value: str) -> str:
        return str(uuid.UUID(value))

    @field_validator('owner_account_uuid')
    @classmethod
    def validate_optional_uuid(cls, value: str | None) -> str | None:
        return None if value is None else str(uuid.UUID(value))


class CloudModelCatalogSnapshot(BaseModel):
    model_config = ConfigDict(extra='forbid', frozen=True)

    instance_uuid: str = Field(min_length=1, max_length=255)
    generated_at: datetime
    base_url: str = Field(min_length=1, max_length=512)
    models: tuple[CloudModelCatalogItem, ...]
    workspaces: tuple[CloudWorkspaceModelBilling, ...]

    @field_validator('base_url')
    @classmethod
    def validate_base_url(cls, value: str) -> str:
        normalized = value.rstrip('/')
        if not normalized.startswith('https://'):
            raise ValueError('Cloud model gateway base URL must use HTTPS')
        return normalized

    @field_validator('models')
    @classmethod
    def validate_models(cls, value: tuple[CloudModelCatalogItem, ...]) -> tuple[CloudModelCatalogItem, ...]:
        if len(value) > 500:
            raise ValueError('Cloud model catalog exceeds 500 models')
        identities = {(item.category, item.uuid) for item in value}
        if len(identities) != len(value):
            raise ValueError('Cloud model catalog contains duplicate model identities')
        return value

    @field_validator('workspaces')
    @classmethod
    def validate_workspaces(
        cls, value: tuple[CloudWorkspaceModelBilling, ...]
    ) -> tuple[CloudWorkspaceModelBilling, ...]:
        if len(value) > 10_000:
            raise ValueError('Cloud model catalog exceeds 10000 Workspaces')
        identities = {item.workspace_uuid for item in value}
        if len(identities) != len(value):
            raise ValueError('Cloud model catalog contains duplicate Workspaces')
        return value


@runtime_checkable
class CloudModelCatalogProvider(Protocol):
    async def fetch_model_catalog(self, instance_uuid: str) -> CloudModelCatalogSnapshot:
        """Fetch and verify the complete model catalog and Workspace billing projection."""
        ...


def system_provider_uuid(workspace_uuid: str) -> str:
    workspace = str(uuid.UUID(workspace_uuid))
    return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:provider:{LANGBOT_MODELS_PROVIDER_REQUESTER}'))


def system_model_uuid(workspace_uuid: str, category: str, upstream_uuid: str) -> str:
    workspace = str(uuid.UUID(workspace_uuid))
    if category not in _SUPPORTED_CATEGORIES:
        raise ValueError(f'Unsupported model category: {category}')
    if not upstream_uuid:
        raise ValueError('Upstream model UUID is required')
    return str(uuid.uuid5(_MODEL_RESOURCE_NAMESPACE, f'{workspace}:model:{category}:{upstream_uuid}'))


class CloudModelCatalogSyncService:
    """Reconcile Space-owned model catalog and Owner billing tokens into every Cloud Workspace."""

    def __init__(
        self,
        ap: Any,
        provider: CloudModelCatalogProvider,
        instance_uuid: str,
        *,
        sync_interval_seconds: float = 3600.0,
    ) -> None:
        if not isinstance(provider, CloudModelCatalogProvider):
            raise TypeError('Cloud model catalog sync requires a CloudModelCatalogProvider')
        if sync_interval_seconds < 10:
            raise ValueError('Cloud model catalog sync interval must be at least 10 seconds')
        self.ap = ap
        self.provider = provider
        self.instance_uuid = instance_uuid
        self.sync_interval_seconds = float(sync_interval_seconds)
        # A tenant UoW commits one Workspace at a time. Keep a durable in-memory
        # convergence marker so a failed runtime reload is retried even when the
        # following database reconciliation is a no-op.
        self._runtime_reload_pending = False
        self._workspace_credits: dict[str, int | None] = {}
        self._sync_requested = asyncio.Event()

    def get_workspace_credits(self, workspace_uuid: str) -> int | None:
        """Return the latest signed owner-credit projection for a Workspace."""
        return self._workspace_credits.get(str(uuid.UUID(workspace_uuid)))

    async def initialize(self) -> None:
        await self.sync_once(reload_runtime=False)

    def request_sync(self) -> None:
        """Wake the catalog loop after a directory Workspace change."""

        self._sync_requested.set()

    async def run(self) -> None:
        while True:
            try:
                await asyncio.wait_for(self._sync_requested.wait(), timeout=self.sync_interval_seconds)
            except TimeoutError:
                pass
            self._sync_requested.clear()
            try:
                await self.sync_once(reload_runtime=True)
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                # Exception messages can contain rendered SQL bound values,
                # including provider API keys. Log only the exception class.
                self.ap.logger.warning(f'Cloud model catalog synchronization failed ({type(exc).__name__})')

    async def sync_once(self, *, reload_runtime: bool = True) -> dict[str, int]:
        summary = {'workspaces': 0, 'created': 0, 'updated': 0, 'deleted': 0}
        snapshot: CloudModelCatalogSnapshot | None = None
        sync_error: Exception | None = None
        reload_error: Exception | None = None
        try:
            snapshot = await self.provider.fetch_model_catalog(self.instance_uuid)
            if snapshot.instance_uuid != self.instance_uuid:
                raise ValueError('Cloud model catalog targets another LangBot instance')

            bindings = await self.ap.workspace_service.list_active_execution_bindings()
            billing_by_workspace = {item.workspace_uuid: item for item in snapshot.workspaces}
            missing = sorted(
                binding.workspace_uuid for binding in bindings if binding.workspace_uuid not in billing_by_workspace
            )
            if missing:
                raise ValueError(
                    f'Cloud model catalog is missing billing projections for {len(missing)} active Workspaces'
                )

            for binding in bindings:
                counts = await self._sync_workspace(
                    binding.workspace_uuid,
                    snapshot,
                    billing_by_workspace[binding.workspace_uuid],
                )
                summary['workspaces'] += 1
                workspace_changed = any(counts[key] > 0 for key in ('created', 'updated', 'deleted'))
                if workspace_changed:
                    # _sync_workspace returns only after its tenant UoW commits.
                    self._runtime_reload_pending = True
                for key in ('created', 'updated', 'deleted'):
                    summary[key] += counts[key]
                self._workspace_credits[binding.workspace_uuid] = billing_by_workspace[binding.workspace_uuid].credits
        except Exception as exc:
            sync_error = exc
        finally:
            model_mgr = getattr(self.ap, 'model_mgr', None)
            if reload_runtime and self._runtime_reload_pending and model_mgr is not None:
                try:
                    await model_mgr.load_models_from_db()
                except Exception as exc:
                    reload_error = exc
                else:
                    self._runtime_reload_pending = False

        if sync_error is not None:
            if reload_error is not None:
                raise sync_error from reload_error
            raise sync_error
        if reload_error is not None:
            raise reload_error

        changed = any(summary[key] > 0 for key in ('created', 'updated', 'deleted'))
        if changed and snapshot is not None:
            self.ap.logger.info(
                'Cloud model catalog synchronized '
                f'({summary["workspaces"]} Workspaces, {len(snapshot.models)} models, '
                f'created={summary["created"]}, updated={summary["updated"]}, deleted={summary["deleted"]})'
            )
        return summary

    async def _sync_workspace(
        self,
        workspace_uuid: str,
        snapshot: CloudModelCatalogSnapshot,
        billing: CloudWorkspaceModelBilling,
    ) -> dict[str, int]:
        counts = {'created': 0, 'updated': 0, 'deleted': 0}
        provider_uuid = system_provider_uuid(workspace_uuid)
        desired_keys = [billing.api_key.get_secret_value()] if billing.api_key is not None else []

        async with self.ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
            provider = await uow.session.scalar(
                sqlalchemy.select(persistence_model.ModelProvider).where(
                    persistence_model.ModelProvider.uuid == provider_uuid
                )
            )
            provider_values = {
                'workspace_uuid': workspace_uuid,
                'name': LANGBOT_MODELS_PROVIDER_NAME,
                'requester': LANGBOT_MODELS_PROVIDER_REQUESTER,
                'base_url': snapshot.base_url,
                'api_keys': desired_keys,
            }
            if provider is None:
                provider = persistence_model.ModelProvider(uuid=provider_uuid, **provider_values)
                uow.session.add(provider)
                await uow.session.flush()
                counts['created'] += 1
            elif self._update_entity(provider, provider_values):
                counts['updated'] += 1

            existing_by_table: dict[type, dict[str, Any]] = {}
            for table in _MODEL_TABLES:
                rows = (
                    await uow.session.scalars(sqlalchemy.select(table).where(table.provider_uuid == provider_uuid))
                ).all()
                existing_by_table[table] = {row.uuid: row for row in rows}

            desired_ids: dict[type, set[str]] = {table: set() for table in _MODEL_TABLES}
            for item in snapshot.models:
                table, values = self._model_values(workspace_uuid, provider_uuid, item)
                model_uuid = system_model_uuid(workspace_uuid, item.category, item.uuid)
                desired_ids[table].add(model_uuid)
                existing = existing_by_table[table].get(model_uuid)
                if existing is None:
                    uow.session.add(table(uuid=model_uuid, **values))
                    counts['created'] += 1
                elif self._update_entity(existing, values):
                    counts['updated'] += 1

            for table, entities in existing_by_table.items():
                for model_uuid, entity in entities.items():
                    if model_uuid not in desired_ids[table]:
                        await uow.session.delete(entity)
                        counts['deleted'] += 1

        return counts

    @staticmethod
    def _update_entity(entity: Any, values: dict[str, Any]) -> bool:
        changed = False
        for key, value in values.items():
            if getattr(entity, key) != value:
                setattr(entity, key, value)
                changed = True
        return changed

    @staticmethod
    def _model_values(
        workspace_uuid: str,
        provider_uuid: str,
        item: CloudModelCatalogItem,
    ) -> tuple[type, dict[str, Any]]:
        ranking = 100 - item.featured_order if item.is_featured else 0
        common = {
            'workspace_uuid': workspace_uuid,
            'name': item.model_id,
            'provider_uuid': provider_uuid,
            'extra_args': {},
            'prefered_ranking': ranking,
        }
        if item.category == 'chat':
            return persistence_model.LLMModel, {
                **common,
                'abilities': list(item.llm_abilities),
                'context_length': None,
            }
        if item.category == 'embedding':
            return persistence_model.EmbeddingModel, common
        if item.category == 'rerank':
            return persistence_model.RerankModel, common
        raise ValueError(f'Unsupported model category: {item.category}')
