"""Thread-safe in-memory session store for datasets and results.

Singleton pattern with RLock guard. Stores:
  - Datasets: dataset_id -> (LazyFrame, schema, metadata)
  - Cluster results: result_id -> (labels, n_clusters, params, parent_dataset_id)
  - Feature results: result_id -> (features, parent_cluster_result_id)
"""
import json
import os
import threading
import gc
import uuid
from pathlib import Path
from typing import Any, Optional

import polars as pl


# Approximate per-element byte sizes for memory estimation
_DTYPE_BYTE_SIZES = {
    # Polars dtype strings
    'Int8': 1, 'Int16': 2, 'Int32': 4, 'Int64': 8,
    'UInt8': 1, 'UInt16': 2, 'UInt32': 4, 'UInt64': 8,
    'Float32': 4, 'Float64': 8,
    'Boolean': 1, 'Bool': 1,
    'Utf8': 50, 'String': 50, 'Categorical': 8,
    'Date': 4, 'Datetime': 8, 'Time': 8, 'Duration': 8,
    'List': 16, 'Object': 16, 'Null': 0,
    # Polars __str__ representations
    'i32': 4, 'i64': 8, 'u32': 4, 'u64': 8,
    'f32': 4, 'f64': 8, 'str': 50, 'bool': 1,
}


def _estimate_dtype_bytes(dtype: Any) -> int:
    """Guess per-element byte size from a Polars dtype."""
    key = str(dtype)
    for prefix, size in _DTYPE_BYTE_SIZES.items():
        if key.startswith(prefix) or key == prefix:
            return size
    return 8  # fallback


def _generate_id(prefix: str = 'ds') -> str:
    """Generate a short unique identifier."""
    return f"{prefix}_{uuid.uuid4().hex[:12]}"


def _get_store_path() -> Path:
    """Return the persistent JSON file path for dataset metadata.

    Uses ``%APPDATA%/TianXuan/.session_store.json`` (Windows) or
    ``~/.tianxuan_session_store.json`` (other platforms).
    """
    from analysis.appdata import get_appdata_dir
    store_dir = get_appdata_dir()
    store_dir.mkdir(parents=True, exist_ok=True)
    return store_dir / '.session_store.json'


class SessionStore:
    """Singleton in-memory store for datasets and analysis results.

    Thread-safe via RLock. Datasets store Polars LazyFrames (query plans,
    not materialised data), so clone/shallow-copy is effectively free.

    Dataset metadata is persisted to ``_get_store_path()`` so that after a
    server restart datasets whose CSV files still exist can be re-loaded.
    """

    _instance: Optional['SessionStore'] = None
    _lock: threading.RLock = threading.RLock()

    def __new__(cls) -> 'SessionStore':
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._stores: dict[str, dict[str, Any]] = {}
                    cls._instance._datalock = threading.RLock()
        return cls._instance

    # ── persistence ────────────────────────────────────────────────────

    def _save_to_disk(self) -> None:
        """Persist current dataset metadata to JSON file.

        Only metadata (schema, csv_glob, row_count, …) is saved — never the
        LazyFrame itself, which is an in-memory query plan.
        """
        with self._datalock:
            serializable = {}
            for ds_id, entry in self._stores.items():
                if entry.get('type') != 'dataset':
                    continue
                meta = entry.get('metadata', {})
                serializable[ds_id] = {
                    'schema': entry.get('schema'),
                    'metadata': meta,
                    'csv_glob': meta.get('csv_glob'),
                }
        path = _get_store_path()
        path.write_text(
            json.dumps(serializable, ensure_ascii=False, indent=2),
            encoding='utf-8',
        )

    def restore_from_disk(self) -> int:
        """Re-register datasets from persisted metadata.

        Attempts two strategies in order:
        1. **SQLite table** — when ``metadata.sqlite_table`` exists (fast,
           always available, no CSV dependency).
        2. **CSV glob** — falls back to ``load_csv_directory`` for datasets
           that were stored before the SQLite migration or whose SQLite table
           was dropped.

        Returns the number of successfully restored datasets.
        """
        path = _get_store_path()
        if not path.exists():
            return 0
        try:
            data = json.loads(path.read_text(encoding='utf-8'))
        except (json.JSONDecodeError, OSError):
            return 0

        # Lazy imports to avoid circular dependency at module level
        from analysis.data_loader import load_csv_directory, load_from_db  # fmt: skip

        restored = 0
        for ds_id, info in data.items():
            meta = dict(info.get('metadata', {}))
            schema = dict(info.get('schema', {}))
            restored_ok = False

            # Strategy 1: SQLite table (fast path)
            sqlite_table = meta.get('sqlite_table')
            if sqlite_table:
                try:
                    lf = load_from_db(sqlite_table)
                    if lf is not None:
                        self.store_dataset(ds_id, lf, schema=schema, metadata=meta)
                        restored += 1
                        restored_ok = True
                except Exception:
                    pass  # fall through to CSV restore

            if restored_ok:
                continue

            # Strategy 2: CSV glob (legacy / fallback)
            csv_glob = info.get('csv_glob')
            if not csv_glob:
                continue
            try:
                lf, schema_csv, row_count, file_count, memory_mb = load_csv_directory(csv_glob)
                # Preserve original metadata counts from disk — CSV re-scan may
                # detect different file/row counts if files changed on disk.
                if 'file_count' not in meta and file_count is not None:
                    meta['file_count'] = file_count
                if 'row_count' not in meta and row_count is not None:
                    meta['row_count'] = row_count
                self.store_dataset(ds_id, lf, schema=schema_csv, metadata=meta)
                restored += 1
            except Exception:
                # File(s) no longer exist or schema changed — skip gracefully
                continue
        return restored

    # ── dataset operations ──────────────────────────────────────────────

    def store_dataset(
        self,
        dataset_id: str,
        lazyframe: pl.LazyFrame,
        schema: Optional[dict] = None,
        metadata: Optional[dict] = None,
        parent_id: Optional[str] = None,
    ) -> None:
        """Store a LazyFrame under *dataset_id*.

        Args:
            dataset_id: Unique key for the dataset.
            lazyframe: Polars LazyFrame (query plan, not materialised).
            schema: Column schema dict {name: dtype_string}.
            metadata: Optional dict (row_count, file_count, …).
            parent_id: If this is a derived dataset (filter/clone/…), the
                source dataset_id.
        """
        with self._datalock:
            self._stores[dataset_id] = {
                'type': 'dataset',
                'lazyframe': lazyframe,
                'schema': schema or {},
                'metadata': metadata or {},
                'parent_id': parent_id,
            }
        self._save_to_disk()

    def get_dataset(self, dataset_id: str) -> Optional[dict]:
        """Retrieve a stored dataset entry (or *None*)."""
        with self._datalock:
            entry = self._stores.get(dataset_id)
            if entry and entry.get('type') == 'dataset':
                return entry
            return None

    def drop_dataset(self, dataset_id: str) -> bool:
        """Remove a dataset from the store and force garbage collection.

        Returns True if the dataset existed.
        """
        with self._datalock:
            existed = dataset_id in self._stores and self._stores[dataset_id].get('type') == 'dataset'
            if existed:
                del self._stores[dataset_id]
        if existed:
            gc.collect()
            self._save_to_disk()
        return existed

    def clone_dataset(self, dataset_id: str) -> Optional[str]:
        """Shallow clone — LazyFrame is a query plan so no data is copied.

        Returns the new dataset_id, or *None* if source does not exist.
        """
        with self._datalock:
            entry = self.get_dataset(dataset_id)
            if entry is None:
                return None
            new_id = _generate_id('clone')
            self._stores[new_id] = {
                'type': 'dataset',
                'lazyframe': entry['lazyframe'],  # same plan pointer
                'schema': dict(entry['schema']),
                'metadata': dict(entry.get('metadata', {})),
                'parent_id': dataset_id,
            }
        return new_id

    def list_datasets(self) -> list[dict]:
        """Return summary dict for every stored dataset."""
        with self._datalock:
            results = []
            for ds_id, entry in self._stores.items():
                if entry.get('type') != 'dataset':
                    continue
                meta = entry.get('metadata', {})
                results.append({
                    'dataset_id': ds_id,
                    'parent_id': entry.get('parent_id'),
                    'row_count': meta.get('row_count'),
                    'file_count': meta.get('file_count'),
                    'svd_components': meta.get('svd_components', 0),
                    'columns': list(entry.get('schema', {}).keys()),
                    'column_count': len(entry.get('schema', {})),
                })
        return results

    def memory_estimate_mb(self, dataset_id: str) -> float:
        """Rough memory estimate based on schema × row_count."""
        with self._datalock:
            entry = self.get_dataset(dataset_id)
            if entry is None:
                return 0.0
            schema = entry.get('schema', {})
            row_count = entry.get('metadata', {}).get('row_count', 0)
        if not row_count or not schema:
            return 0.0
        bytes_per_row = sum(_estimate_dtype_bytes(dtype) for dtype in schema.values())
        return (bytes_per_row * row_count) / (1024 * 1024)

    # ── cluster-result operations ───────────────────────────────────────

    def store_cluster_result(
        self,
        result_id: str,
        labels: list[int],
        n_clusters: int,
        params: dict[str, Any],
        parent_dataset_id: str,
    ) -> None:
        """Store a clustering result."""
        with self._datalock:
            self._stores[result_id] = {
                'type': 'cluster_result',
                'labels': labels,
                'n_clusters': n_clusters,
                'params': params,
                'parent_dataset_id': parent_dataset_id,
            }

    def get_cluster_result(self, result_id: str) -> Optional[dict]:
        """Retrieve a stored cluster result."""
        with self._datalock:
            entry = self._stores.get(result_id)
            if entry and entry.get('type') == 'cluster_result':
                return entry
            return None

    def drop_cluster_result(self, result_id: str) -> bool:
        """Remove a cluster result."""
        with self._datalock:
            existed = result_id in self._stores and self._stores[result_id].get('type') == 'cluster_result'
            if existed:
                del self._stores[result_id]
        return existed

    # ── feature-result operations ───────────────────────────────────────

    def store_feature_result(
        self,
        result_id: str,
        features: list[dict[str, Any]],
        parent_cluster_result_id: str,
    ) -> None:
        """Store extracted features per cluster."""
        with self._datalock:
            self._stores[result_id] = {
                'type': 'feature_result',
                'features': features,
                'parent_cluster_result_id': parent_cluster_result_id,
            }

    def get_feature_result(self, result_id: str) -> Optional[dict]:
        """Retrieve a stored feature result."""
        with self._datalock:
            entry = self._stores.get(result_id)
            if entry and entry.get('type') == 'feature_result':
                return entry
            return None

    def drop_feature_result(self, result_id: str) -> bool:
        """Remove a feature result."""
        with self._datalock:
            existed = result_id in self._stores and self._stores[result_id].get('type') == 'feature_result'
            if existed:
                del self._stores[result_id]
        return existed

    # ── general ─────────────────────────────────────────────────────────

    def drop_all(self) -> int:
        """Drop every entry and run gc. Returns count of removed entries."""
        with self._datalock:
            count = len(self._stores)
            self._stores.clear()
        if count:
            gc.collect()
        return count

    def __repr__(self) -> str:
        with self._datalock:
            types = {}
            for entry in self._stores.values():
                t = entry.get('type', 'unknown')
                types[t] = types.get(t, 0) + 1
        parts = [f"{k}={v}" for k, v in sorted(types.items())]
        return f"<SessionStore {'; '.join(parts)}>"
