"""
EpJSON Schema loader and manager.

Handles loading and caching of Energy+.schema.epJSON files
for different EnergyPlus versions. Supports both uncompressed
and gzip-compressed schema files.
"""

from __future__ import annotations

import gzip
import json
import logging
import os
import time
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any, ClassVar, cast

from .exceptions import SchemaNotFoundError
from .versions import (
    ENERGYPLUS_VERSIONS,
    find_closest_version,
    version_dirname,
)

logger = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class ParsingCache:
    """Pre-computed parsing metadata for a single object type.

    Built lazily on first access per object type and cached for reuse.
    Eliminates repeated nested dict traversals during parsing.
    """

    obj_schema: dict[str, Any]
    has_name: bool
    field_names: tuple[str, ...]
    all_field_names: tuple[str, ...]
    field_types: dict[str, str | None]
    ref_fields: frozenset[str]
    extensible: bool
    ext_size: int
    ext_field_names: tuple[str, ...]


def _resolve_field_type(
    field_name: str,
    props: dict[str, Any],
    field_info: dict[str, Any],
) -> str | None:
    """Resolve the type string for a single field from pre-extracted schema dicts."""
    field_schema = props.get(field_name)
    if field_schema is None:
        fi = field_info.get(field_name)
        if fi:
            ft = fi.get("field_type")
            if ft == "n":
                return "number"
            if ft == "a":
                return "string"
        return None

    if "anyOf" in field_schema:
        for sub in field_schema["anyOf"]:
            if sub.get("type") in ("number", "integer"):
                return sub["type"]
        return "string"

    return field_schema.get("type")


class EpJSONSchema:
    """
    Wrapper around Energy+.schema.epJSON providing easy access to object definitions.

    The schema contains:
    - Object definitions with field types, defaults, constraints
    - Reference lists (object-list) for cross-object validation
    - Legacy IDD info for IDF field ordering

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> "Zone" in schema
        True

        Check which IDD group a type belongs to:

        >>> schema.get_group("Zone")
        'Thermal Zones and Surfaces'

        Some object types (like Timestep) are singletons with no name field:

        >>> schema.has_name("Zone")
        True
        >>> schema.has_name("Timestep")
        False

    Attributes:
        version: The EnergyPlus version tuple
        _raw: The raw schema dict
        _properties: Object definitions
    """

    __slots__ = (
        "_object_lists",
        "_parsing_cache",
        "_properties",
        "_raw",
        "_reference_lists",
        "_upper_to_canonical",
        "version",
    )

    version: tuple[int, int, int]
    _raw: dict[str, Any]
    _properties: dict[str, Any]
    _reference_lists: dict[str, list[str]]
    _object_lists: dict[str, set[str]]
    _parsing_cache: dict[str, ParsingCache]
    _upper_to_canonical: dict[str, str]

    def __init__(self, version: tuple[int, int, int], schema_data: dict[str, Any]) -> None:
        self.version = version
        self._raw = schema_data
        self._properties: dict[str, Any] = schema_data.get("properties", {})

        # Case-insensitive lookup: UPPER → canonical type name
        self._upper_to_canonical: dict[str, str] = {k.upper(): k for k in self._properties}

        # Build reference indexes
        self._reference_lists: dict[str, list[str]] = {}
        self._object_lists: dict[str, set[str]] = {}
        self._parsing_cache: dict[str, ParsingCache] = {}
        self._build_reference_indexes()

    def _build_reference_indexes(self) -> None:
        """Build indexes for reference and object lists."""
        for obj_type, obj_schema in self._properties.items():
            # Check if this object provides names for any reference lists
            name_info = obj_schema.get("name", {})
            if "reference" in name_info:
                for ref_list in name_info["reference"]:
                    if ref_list not in self._reference_lists:
                        self._reference_lists[ref_list] = []
                    self._reference_lists[ref_list].append(obj_type)

            # Find fields that reference object lists
            pattern_props: dict[str, Any] = obj_schema.get("patternProperties", {})
            default_dict: dict[str, Any] = {}
            inner: dict[str, Any] = next(iter(pattern_props.values()), default_dict) if pattern_props else default_dict
            props: dict[str, Any] = inner.get("properties", {})
            for field_name, field_schema in props.items():
                field_schema_dict: dict[str, Any] = field_schema
                if "object_list" in field_schema_dict:
                    for obj_list in field_schema_dict["object_list"]:
                        if obj_list not in self._object_lists:
                            self._object_lists[obj_list] = set()
                        self._object_lists[obj_list].add(f"{obj_type}.{field_name}")

    def get_object_schema(self, obj_type: str) -> dict[str, Any] | None:
        """Get the full schema for an object type.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> zone_schema = schema.get_object_schema("Zone")
            >>> zone_schema is not None
            True
            >>> schema.get_object_schema("NonExistent") is None
            True
        """
        return self._properties.get(obj_type)

    def get_inner_schema(self, obj_type: str) -> dict[str, Any] | None:
        """Get the inner schema (inside patternProperties) for an object type."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return None
        pattern_props = obj_schema.get("patternProperties", {})
        # The pattern key varies (e.g., ".*", "^.*\\S.*$") - get the first one
        for key in pattern_props:
            return pattern_props[key]
        return None

    def get_field_schema(self, obj_type: str, field_name: str) -> dict[str, Any] | None:
        """Get schema for a specific field of an object type."""
        inner = self.get_inner_schema(obj_type)
        if not inner:
            return None
        return inner.get("properties", {}).get(field_name)

    def get_field_names(self, obj_type: str) -> list[str]:
        """Get ordered list of field names for an object type (from legacy_idd).

        Useful for discovering valid field names when building objects
        programmatically.

        Examples:
            List the fields available on a Material object:

            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> "thickness" in schema.get_field_names("Material")
            True
        """
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return []
        legacy = obj_schema.get("legacy_idd", {})
        fields = legacy.get("fields", [])
        # First field is 'name', return the rest
        return fields[1:] if fields else []

    def get_all_field_names(self, obj_type: str) -> list[str]:
        """Get all field names including 'name'."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return []
        legacy = obj_schema.get("legacy_idd", {})
        return list(legacy.get("fields", []))

    def get_required_fields(self, obj_type: str) -> list[str]:
        """Get list of required field names for an object type.

        Check which fields must be supplied before a Material is valid:

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> schema.get_required_fields("Material")
            ['roughness', 'thickness', 'conductivity', 'density', 'specific_heat']
        """
        inner = self.get_inner_schema(obj_type)
        if not inner:
            return []
        return inner.get("required", [])

    def get_field_default(self, obj_type: str, field_name: str) -> Any:
        """Get default value for a field."""
        field_schema = self.get_field_schema(obj_type, field_name)
        if field_schema:
            return field_schema.get("default")
        return None

    def get_field_type(self, obj_type: str, field_name: str) -> str | None:
        """Get the type of a field ('number', 'string', 'integer', 'array').

        Useful for dynamic type coercion when importing data from
        spreadsheets or CSV files.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> schema.get_field_type("Material", "thickness")
            'number'
            >>> schema.get_field_type("Material", "roughness")
            'string'
        """
        field_schema = self.get_field_schema(obj_type, field_name)
        if not field_schema:
            # Fall back to legacy_idd field_info for extensible fields
            obj_schema = self.get_object_schema(obj_type)
            if obj_schema:
                field_info = obj_schema.get("legacy_idd", {}).get("field_info", {}).get(field_name)
                if field_info:
                    ft = field_info.get("field_type")
                    if ft == "n":
                        return "number"
                    if ft == "a":
                        return "string"
            return None

        # Handle anyOf (e.g., number OR "Autocalculate")
        if "anyOf" in field_schema:
            for sub in field_schema["anyOf"]:
                if sub.get("type") in ("number", "integer"):
                    return sub["type"]
            return "string"

        return field_schema.get("type")

    def get_field_object_list(self, obj_type: str, field_name: str) -> list[str] | None:
        """Get the object_list(s) that a field references."""
        field_schema = self.get_field_schema(obj_type, field_name)
        if field_schema:
            return field_schema.get("object_list")
        return None

    def is_reference_field(self, obj_type: str, field_name: str) -> bool:
        """Check if a field is a reference to another object."""
        return self.get_field_object_list(obj_type, field_name) is not None

    def resolve_type_name(self, obj_type: str) -> str | None:
        """Resolve a possibly-miscased type name to its canonical schema form.

        EnergyPlus is case-insensitive for object type names, so IDF files may
        use ``ZONE``, ``zone``, or ``Zone`` interchangeably.  This method
        returns the canonical PascalCase name from the schema, or ``None`` if
        the type is not recognised.
        """
        if obj_type in self._properties:
            return obj_type  # Fast path: already canonical
        return self._upper_to_canonical.get(obj_type.upper())

    def get_parsing_cache(self, obj_type: str) -> ParsingCache | None:
        """Get or lazily build pre-computed parsing metadata for an object type.

        The lookup is case-insensitive: ``ZONE``, ``zone``, and ``Zone`` all
        resolve to the same cache entry.  Returns ``None`` if *obj_type* is
        not in the schema.
        """
        cached = self._parsing_cache.get(obj_type)
        if cached is not None:
            return cached

        canonical = self.resolve_type_name(obj_type)
        if canonical is None:
            return None

        # Check if the canonical form is already cached (different casing was
        # looked up before).
        cached = self._parsing_cache.get(canonical)
        if cached is None:
            cached = self._build_parsing_cache(canonical, self._properties[canonical])
            self._parsing_cache[canonical] = cached

        # Also cache under the original key for future fast-path hits.
        if obj_type != canonical:
            self._parsing_cache[obj_type] = cached
        return cached

    def _build_parsing_cache(self, obj_type: str, obj_schema: dict[str, Any]) -> ParsingCache:
        """Build parsing metadata for a single object type."""
        has_name = "name" in obj_schema

        legacy: dict[str, Any] = obj_schema.get("legacy_idd", {})
        all_fields_list: list[str] = legacy.get("fields", [])
        all_field_names = tuple(all_fields_list)
        field_names = tuple(all_fields_list[1:]) if all_fields_list else ()

        # Extract inner schema properties
        pattern_props: dict[str, Any] = obj_schema.get("patternProperties", {})
        default_dict: dict[str, Any] = {}
        inner: dict[str, Any] = next(iter(pattern_props.values()), default_dict) if pattern_props else default_dict
        props: dict[str, Any] = inner.get("properties", {})
        # Extract extensible field schemas from array items (for reference detection)
        ext_props: dict[str, Any] = {}
        for _pk in list(props):
            _pv: dict[str, Any] = props[_pk]
            _items = _pv.get("items")
            if isinstance(_items, dict):
                _ep = cast("dict[str, Any]", _items).get("properties")
                if isinstance(_ep, dict):
                    ext_props = cast("dict[str, Any]", _ep)

        field_info: dict[str, Any] = legacy.get("field_info", {})

        # Pre-compute field types and reference fields
        field_types: dict[str, str | None] = {}
        ref_fields_set: set[str] = set()
        target_fields = field_names if has_name else all_field_names
        for fname in target_fields:
            field_types[fname] = _resolve_field_type(fname, props, field_info)
            field_schema = props.get(fname)
            if field_schema is not None and "object_list" in field_schema:
                ref_fields_set.add(fname)

        # Extensible info
        extensible = "extensible_size" in obj_schema
        ext_size = int(obj_schema.get("extensible_size", 0))
        ext_field_names_list: list[str] = legacy.get("extensibles", [])

        # Pre-compute field types and reference fields for extensible base names
        for ext_fname in ext_field_names_list:
            if ext_fname not in field_types:
                field_types[ext_fname] = _resolve_field_type(ext_fname, props, field_info)
            ext_field_schema = ext_props.get(ext_fname)
            if ext_field_schema is not None and "object_list" in ext_field_schema:
                ref_fields_set.add(ext_fname)

        return ParsingCache(
            obj_schema=obj_schema,
            has_name=has_name,
            field_names=field_names,
            all_field_names=all_field_names,
            field_types=field_types,
            ref_fields=frozenset(ref_fields_set),
            extensible=extensible,
            ext_size=ext_size,
            ext_field_names=tuple(ext_field_names_list),
        )

    def get_types_providing_reference(self, ref_list: str) -> list[str]:
        """Get object types that provide names for a reference list."""
        return self._reference_lists.get(ref_list, [])

    def get_group(self, obj_type: str) -> str | None:
        """Get the IDD group name for an object type.

        Every object type in the EnergyPlus schema belongs to a group
        (e.g. ``"Thermal Zones and Surfaces"``, ``"HVAC Templates"``,
        ``"Detailed Ground Heat Transfer"``).  This method returns the
        group string, which is useful for classifying objects without
        relying on naming conventions.

        Args:
            obj_type: Case-sensitive EnergyPlus object type
                (e.g. ``"Zone"``, ``"HVACTemplate:Zone:IdealLoadsAirSystem"``).

        Returns:
            The group name, or ``None`` if *obj_type* is not in the schema.

        Examples:
            ```python
            schema = get_schema((24, 1, 0))
            schema.get_group("Zone")
            # "Thermal Zones and Surfaces"
            schema.get_group("HVACTemplate:Zone:IdealLoadsAirSystem")
            # "HVAC Templates"
            ```
        """
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return obj_schema.get("group")
        return None

    def get_object_memo(self, obj_type: str) -> str | None:
        """Get the memo/description for an object type."""
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return obj_schema.get("memo")
        return None

    def has_name(self, obj_type: str) -> bool:
        """Check if an object type has a name field (first IDF field is a name)."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return True  # Default: assume named (backward compat)
        return "name" in obj_schema

    def get_extensible_field_names(self, obj_type: str) -> list[str]:
        """Get extensible field names from legacy_idd.extensibles."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return []
        legacy = obj_schema.get("legacy_idd", {})
        return legacy.get("extensibles", [])

    def is_extensible(self, obj_type: str) -> bool:
        """Check if an object type has extensible fields.

        Extensible types (like surfaces) can have a variable number of
        vertices or layers.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> schema.is_extensible("BuildingSurface:Detailed")
            True
            >>> schema.is_extensible("Zone")
            False
        """
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return "extensible_size" in obj_schema
        return False

    def get_extensible_size(self, obj_type: str) -> int | None:
        """Get the extensible group size for an object type."""
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return obj_schema.get("extensible_size")
        return None

    @property
    def object_types(self) -> list[str]:
        """Get list of all object types in the schema.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> "Zone" in schema.object_types
            True
            >>> len(schema.object_types) > 100
            True
        """
        return list(self._properties.keys())

    def __contains__(self, obj_type: str) -> bool:
        """Check if an object type exists in the schema."""
        return obj_type in self._properties

    def __len__(self) -> int:
        """Return number of object types."""
        return len(self._properties)


_SCHEMA_FILENAME = "Energy+.schema.epJSON"
_SCHEMA_FILENAME_GZ = "Energy+.schema.epJSON.gz"


def load_schema_json(path: Path) -> dict[str, Any]:
    """Load a schema JSON file, handling both plain and gzip-compressed files."""
    if path.suffix == ".gz" or path.name.endswith(".epJSON.gz"):
        with gzip.open(path, "rt", encoding="utf-8") as f:
            return json.load(f)
    with open(path, encoding="utf-8") as f:
        return json.load(f)


class SchemaManager:
    """
    Manages loading and caching of EpJSON schemas for different versions.

    Searches for schemas in the following order:
    1. Bundled schemas directory (shipped with idfkit) - both .gz and plain
    2. User cache directory (~/.idfkit/schemas/)
    3. EnergyPlus installation directories

    Supports gzip-compressed schema files (.epJSON.gz) to reduce package size.
    """

    # Common EnergyPlus installation paths by platform
    _INSTALL_PATHS: ClassVar[dict[str, list[str]]] = {
        "linux": ["/usr/local/EnergyPlus-{v}", "/opt/EnergyPlus-{v}"],
        "darwin": ["/Applications/EnergyPlus-{v}"],
        "win32": [
            "C:\\EnergyPlusV{v}",
            "C:\\EnergyPlus-{v}",
            os.path.expandvars("$LOCALAPPDATA\\EnergyPlusV{v}"),
        ],
    }

    def __init__(
        self,
        bundled_schema_dir: Path | None = None,
        cache_dir: Path | None = None,
    ):
        """
        Initialize the schema manager.

        Args:
            bundled_schema_dir: Path to directory with bundled schema files.
                               If None, uses default location next to this file.
            cache_dir: Path to user cache directory for downloaded schemas.
                       If None, uses ~/.idfkit/schemas/.
        """
        if bundled_schema_dir is None:
            bundled_schema_dir = Path(__file__).parent / "schemas"

        if cache_dir is None:
            cache_dir = Path.home() / ".idfkit" / "schemas"

        self._bundled_dir = bundled_schema_dir
        self._cache_dir = cache_dir
        self._cache: dict[tuple[int, int, int], EpJSONSchema] = {}

    @property
    def bundled_dir(self) -> Path:
        """Path to the bundled schemas directory."""
        return self._bundled_dir

    @property
    def cache_dir(self) -> Path:
        """Path to the user cache directory for schemas."""
        return self._cache_dir

    @lru_cache(maxsize=8)  # noqa: B019
    def get_schema(self, version: tuple[int, int, int]) -> EpJSONSchema:
        """
        Load and return schema for a specific version.

        If the exact version is not found, attempts to find the closest
        supported version that is <= the requested version.

        Args:
            version: EnergyPlus version tuple (major, minor, patch)

        Returns:
            EpJSONSchema for the requested version

        Raises:
            SchemaNotFoundError: If schema cannot be found
        """
        if version in self._cache:
            logger.debug("Schema cache hit for version %d.%d.%d", *version)
            return self._cache[version]

        t0 = time.perf_counter()

        # Try exact version first
        schema_path = self._find_schema_file(version)
        if schema_path is None:
            # Try closest supported version
            closest = find_closest_version(version)
            if closest is not None and closest != version:
                logger.debug(
                    "Exact schema not found for %d.%d.%d, falling back to closest %d.%d.%d",
                    *version,
                    *closest,
                )
                schema_path = self._find_schema_file(closest)

        if schema_path is None:
            searched = self._get_searched_paths(version)
            raise SchemaNotFoundError(version, searched)

        logger.debug("Loading schema from %s", schema_path)
        data = load_schema_json(schema_path)

        schema = EpJSONSchema(version, data)
        self._cache[version] = schema

        elapsed = time.perf_counter() - t0
        logger.info(
            "Loaded schema for version %d.%d.%d (%d object types) in %.3fs",
            *version,
            len(schema),
            elapsed,
        )
        return schema

    def _find_schema_file(self, version: tuple[int, int, int]) -> Path | None:
        """
        Find the schema file for a version.

        Searches bundled directory first (both compressed and plain),
        then user cache, then EnergyPlus installations.
        """
        # Try bundled schemas first
        for path in self._get_bundled_paths(version):
            if path.exists():
                return path

        # Try user cache
        for path in self._get_cache_paths(version):
            if path.exists():
                return path

        # Try EnergyPlus installation
        for path in self._get_install_paths(version):
            if path.exists():
                return path

        return None

    def _get_searched_paths(self, version: tuple[int, int, int]) -> list[str]:
        """Get all paths that would be searched for a version (for error messages)."""
        paths: list[str] = []
        for p in self._get_bundled_paths(version):
            paths.append(str(p))
        for p in self._get_cache_paths(version):
            paths.append(str(p))
        for p in self._get_install_paths(version):
            paths.append(str(p))
        return paths

    def _get_bundled_paths(self, version: tuple[int, int, int]) -> list[Path]:
        """Get potential bundled schema paths for a version."""
        paths: list[Path] = []
        dirname = version_dirname(version)

        # Compressed first (preferred for bundled), then plain
        paths.append(self._bundled_dir / dirname / _SCHEMA_FILENAME_GZ)
        paths.append(self._bundled_dir / dirname / _SCHEMA_FILENAME)

        return paths

    def _get_cache_paths(self, version: tuple[int, int, int]) -> list[Path]:
        """Get potential user cache schema paths for a version."""
        paths: list[Path] = []
        dirname = version_dirname(version)

        paths.append(self._cache_dir / dirname / _SCHEMA_FILENAME_GZ)
        paths.append(self._cache_dir / dirname / _SCHEMA_FILENAME)

        return paths

    def _get_install_paths(self, version: tuple[int, int, int]) -> list[Path]:
        """Get potential EnergyPlus installation schema paths."""
        import sys

        platform = sys.platform
        paths: list[Path] = []
        v = version

        # Get base paths for this platform
        base_patterns: list[str] = self._INSTALL_PATHS.get(platform, self._INSTALL_PATHS.get("linux", []))

        version_formats = [
            f"{v[0]}-{v[1]}-{v[2]}",
            f"{v[0]}.{v[1]}.{v[2]}",
            f"{v[0]}-{v[1]}",
        ]

        for base_pattern in base_patterns:
            for v_fmt in version_formats:
                base_path = Path(base_pattern.format(v=v_fmt))
                paths.append(base_path / _SCHEMA_FILENAME)

        return paths

    def get_available_versions(self) -> list[tuple[int, int, int]]:  # noqa: C901
        """
        Get list of versions with available schemas.

        Checks bundled schemas, user cache, and installed EnergyPlus versions.
        """
        versions: set[tuple[int, int, int]] = set()

        # Check bundled
        if self._bundled_dir.exists():
            for item in self._bundled_dir.iterdir():
                if item.is_dir():
                    version = self._parse_version_from_dirname(item.name)
                    if version and self._dir_has_schema(item):
                        versions.add(version)

        # Check user cache
        if self._cache_dir.exists():
            for item in self._cache_dir.iterdir():
                if item.is_dir():
                    version = self._parse_version_from_dirname(item.name)
                    if version and self._dir_has_schema(item):
                        versions.add(version)

        # Check installed EnergyPlus versions
        import sys

        platform = sys.platform
        base_patterns: list[str] = self._INSTALL_PATHS.get(platform, self._INSTALL_PATHS.get("linux", []))

        for pattern in base_patterns:
            # Look for existing directories matching the pattern
            parent = Path(pattern.split("{v}")[0])
            if parent.exists():
                for item in parent.iterdir():
                    if item.is_dir() and "EnergyPlus" in item.name:
                        version = self._parse_version_from_dirname(item.name)
                        if version:
                            schema_path = item / _SCHEMA_FILENAME
                            if schema_path.exists():
                                versions.add(version)

        return sorted(versions)

    @staticmethod
    def _dir_has_schema(directory: Path) -> bool:
        """Check if a directory contains a schema file (plain or compressed)."""
        return (directory / _SCHEMA_FILENAME).exists() or (directory / _SCHEMA_FILENAME_GZ).exists()

    @staticmethod
    def _parse_version_from_dirname(dirname: str) -> tuple[int, int, int] | None:
        """Parse version tuple from directory name."""
        import re

        # Match patterns like "9-2-0", "9.2.0", "V9-2-0", "EnergyPlus-9-2-0"
        match = re.search(r"(\d+)[-._](\d+)[-._]?(\d+)?", dirname)
        if match:
            major = int(match.group(1))
            minor = int(match.group(2))
            patch = int(match.group(3)) if match.group(3) else 0
            return (major, minor, patch)
        return None

    def clear_cache(self) -> None:
        """Clear the schema cache."""
        self._cache.clear()
        self.get_schema.cache_clear()

    def get_supported_versions(self) -> list[tuple[int, int, int]]:
        """Get list of all EnergyPlus versions that idfkit supports.

        This returns all versions in the registry, regardless of whether
        schema files are currently available locally.
        """
        return list(ENERGYPLUS_VERSIONS)


# Global schema manager instance
_schema_manager: SchemaManager | None = None


def get_schema_manager() -> SchemaManager:
    """Get the global schema manager instance."""
    global _schema_manager
    if _schema_manager is None:
        _schema_manager = SchemaManager()
    return _schema_manager


def get_schema(version: tuple[int, int, int]) -> EpJSONSchema:
    """Convenience function to get schema for a version.

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> "Zone" in schema
        True
        >>> schema = get_schema((24, 1, 0))
        >>> schema.version
        (24, 1, 0)
    """
    return get_schema_manager().get_schema(version)
