from __future__ import annotations

import os

from ..api.http.context import ExecutionContext
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
from ..core import app


class SkillManager:
    """Workspace-scoped in-memory view of Box-managed skill packages."""

    ap: app.Application

    def __init__(self, ap: app.Application):
        self.ap = ap
        self._skills_by_scope: dict[tuple[str, str, int], dict[str, dict]] = {}

    @staticmethod
    def _execution_context(context: TenantContext) -> ExecutionContext:
        workspace_uuid = require_workspace_uuid(context)
        instance_uuid = str(getattr(context, 'instance_uuid', '') or '').strip()
        generation = getattr(context, 'placement_generation', None)
        if not instance_uuid or isinstance(generation, bool) or not isinstance(generation, int) or generation <= 0:
            raise ValueError('Skill cache requires an explicit fenced execution context')
        return ExecutionContext(
            instance_uuid=instance_uuid,
            workspace_uuid=workspace_uuid,
            placement_generation=generation,
            bot_uuid=getattr(context, 'bot_uuid', None),
            pipeline_uuid=getattr(context, 'pipeline_uuid', None),
            query_uuid=getattr(context, 'query_uuid', None),
        )

    @classmethod
    def _scope_key(cls, context: TenantContext) -> tuple[str, str, int]:
        execution_context = cls._execution_context(context)
        return (
            execution_context.instance_uuid,
            execution_context.workspace_uuid,
            execution_context.placement_generation,
        )

    async def initialize(self):
        try:
            binding = await self.ap.workspace_service.get_execution_binding()
        except Exception:
            self.ap.logger.info('No unambiguous Workspace binding; skill caches will load on demand.')
            return
        await self.reload_skills(
            ExecutionContext(
                instance_uuid=binding.instance_uuid,
                workspace_uuid=binding.workspace_uuid,
                placement_generation=binding.placement_generation,
            )
        )

    async def reload_skills(self, context: TenantContext) -> None:
        execution_context = self._execution_context(context)
        key = self._scope_key(execution_context)
        for existing_key in tuple(self._skills_by_scope):
            if existing_key[:2] == key[:2] and existing_key != key:
                self._skills_by_scope.pop(existing_key, None)
        self._skills_by_scope[key] = {}

        box_service = getattr(self.ap, 'box_service', None)
        if box_service is None or not getattr(box_service, 'available', False):
            self.ap.logger.info(
                f'Box runtime unavailable; skill cache is empty for Workspace {execution_context.workspace_uuid}.'
            )
            return

        validate_locally = bool(getattr(box_service, 'shares_filesystem_with_box', False))
        try:
            dropped = 0
            skills: dict[str, dict] = {}
            for skill_data in await box_service.list_skills(execution_context):
                skill_name = skill_data.get('name')
                if not skill_name:
                    continue
                package_root = str(skill_data.get('package_root', '') or '').strip()
                if validate_locally and package_root and not os.path.isdir(package_root):
                    self.ap.logger.warning(
                        f'Skill "{skill_name}" reported by Box runtime but package_root '
                        f'missing on LangBot filesystem ({package_root}); dropping from cache.'
                    )
                    dropped += 1
                    continue
                skills[skill_name] = skill_data
            self._skills_by_scope[key] = skills
            suffix = f' ({dropped} dropped due to missing package_root)' if dropped else ''
            self.ap.logger.info(f'Loaded {len(skills)} skills for Workspace {execution_context.workspace_uuid}{suffix}')
        except Exception as exc:
            self.ap.logger.warning(f'Failed to load skills for Workspace {execution_context.workspace_uuid}: {exc}')

    async def ensure_loaded(self, context: TenantContext) -> None:
        key = self._scope_key(context)
        if key not in self._skills_by_scope:
            await self.reload_skills(context)

    def get_skills(self, context: TenantContext) -> dict[str, dict]:
        return self._skills_by_scope.get(self._scope_key(context), {})

    def refresh_skill_from_disk(self, context: TenantContext, skill_name: str) -> bool:
        return bool(skill_name) and skill_name in self.get_skills(context)

    def get_skill_by_name(self, context: TenantContext, name: str) -> dict | None:
        return self.get_skills(context).get(name)

    def get_skill_index(self, context: TenantContext, bound_skills: list[str] | None = None) -> str:
        lines: list[str] = []
        for skill in self.get_skills(context).values():
            name = skill.get('name')
            if not name or (bound_skills is not None and name not in bound_skills):
                continue
            display = skill.get('display_name') or name
            description = (skill.get('description') or '').strip().replace('\n', ' ')
            lines.append(f'- {name} ({display}): {description}')
        return 'Available Skills:\n' + '\n'.join(lines) if lines else ''

    def build_skill_aware_prompt_addition(
        self,
        context: TenantContext,
        bound_skills: list[str] | None = None,
    ) -> str:
        skill_index = self.get_skill_index(context, bound_skills)
        if not skill_index:
            return ''
        return (
            '\n\n'
            f'{skill_index}\n\n'
            "When the user's request clearly matches one or more skills "
            'based on their descriptions above, call the `activate` tool with '
            'the skill name to load its full instructions. Only the name and '
            'description are visible here; the actual instructions arrive as '
            'the tool result. If no skill is a clear match, respond normally '
            'without activating any skill.'
        )

    def total_cached_skill_count(self) -> int:
        return sum(len(skills) for skills in self._skills_by_scope.values())
