"""Model — the public-facing facade for LizyML.

Model's responsibilities: assembly and delegation only.

What Model does:
1. Validate Config → derive Specs.
2. Load data via DataSource → DataFrameBuilder.
3. Select components via Registry.
4. Delegate to CVTrainer / Evaluator / RefitTrainer.
5. Store FitResult and RefitResult; expose them via evaluate / predict.

What Model does NOT contain:
- OOF/IF generation logic     → training/oof_assembly.py
- Metric computation          → evaluation/evaluator.py
- Estimator-specific logic    → estimators/<name>/provider.py (via EstimatorProvider)
- Plot implementations        → plots/*
- Persistence details         → persistence/*

Mixin decomposition (H-0042):
- Plot methods      → _model_plots.py (ModelPlotsMixin)
- Table/accessors   → _model_tables.py (ModelTablesMixin)
- Export/load       → _model_persistence.py (ModelPersistenceMixin)
- Splitter/IV build → _model_factories.py (module-level functions)
"""

from __future__ import annotations

import dataclasses
import sys
from datetime import datetime, timezone
from importlib.metadata import version as pkg_version
from pathlib import Path
from typing import Any, Literal

import numpy as np
import numpy.typing as npt
import pandas as pd

from lizyml import __version__
from lizyml.config.loader import load_config
from lizyml.config.schema import BlockedGroupKFoldConfig, LizyMLConfig
from lizyml.core._model_factories import (
    build_inner_valid,
    build_splitter,
    get_provider,
    make_inner_valid_factory,
)
from lizyml.core._model_metrics import assemble_calibrated_metrics, filter_metrics
from lizyml.core._model_persistence import ModelPersistenceMixin
from lizyml.core._model_plots import ModelPlotsMixin
from lizyml.core._model_tables import ModelTablesMixin
from lizyml.core.exceptions import ErrorCode, LizyMLError
from lizyml.core.logging import generate_run_id, get_logger
from lizyml.core.specs.feature_spec import FeatureSpec
from lizyml.core.specs.problem_spec import ProblemSpec
from lizyml.core.train_components import TrainComponents
from lizyml.core.types.artifacts import RunMeta
from lizyml.core.types.fit_result import FitResult
from lizyml.core.types.predict_result import PredictionResult
from lizyml.core.types.tuning_result import TuneProgressCallback, TuningResult
from lizyml.data import dataframe_builder, datasource
from lizyml.data.dataframe_builder import DataFrameComponents
from lizyml.data.fingerprint import compute as fp_compute
from lizyml.estimators.provider import EstimatorProvider
from lizyml.evaluation.evaluator import Evaluator
from lizyml.training.cv_trainer import CVTrainer
from lizyml.training.refit_trainer import RefitResult, RefitTrainer
from lizyml.tuning.search_space import (
    parse_space,
    split_by_category,
    suggest_params,
)
from lizyml.tuning.tuner import Tuner

_log = get_logger("model")

TaskType = Literal["regression", "binary", "multiclass"]

# Default metrics per task when none are specified in config.
_DEFAULT_METRICS: dict[str, list[str]] = {
    "regression": ["rmse", "mae"],
    "binary": ["logloss", "auc"],
    "multiclass": ["logloss", "f1", "accuracy"],
}

_TS_METHODS = frozenset({"time_series", "purged_time_series", "group_time_series"})
_BLOCK_METHODS = frozenset({"blocked_group_kfold"})


class Model(ModelPlotsMixin, ModelTablesMixin, ModelPersistenceMixin):
    """Public-facing facade for LizyML.

    Args:
        config: Config source — a dict, a YAML/JSON file path, or a
            :class:`~lizyml.config.schema.LizyMLConfig` instance.
        data: Optional training DataFrame.  When provided, overrides any
            ``data.path`` from the config (useful for in-memory workflows).

    Example::

        model = Model({"config_version": 1, "task": "regression", ...})
        result = model.fit(data=df)
        metrics = model.evaluate()
        predictions = model.predict(X_new)
    """

    def __init__(
        self,
        config: dict[str, Any] | str | Path | LizyMLConfig,
        *,
        data: pd.DataFrame | None = None,
        output_dir: str | Path | None = None,
    ) -> None:
        if isinstance(config, LizyMLConfig):
            self._cfg = config
        else:
            self._cfg = load_config(config)

        self._data: pd.DataFrame | None = data
        # Constructor arg takes priority over Config (BLUEPRINT §17)
        resolved_dir = output_dir or getattr(self._cfg, "output_dir", None)
        self._output_dir: str | Path | None = resolved_dir
        self._run_dir: Path | None = None
        self._fit_result: FitResult | None = None
        self._refit_result: RefitResult | None = None
        self._metrics: dict[str, Any] | None = None
        self._tuning_result: TuningResult | None = None
        self._y: pd.Series | None = None  # transient; not persisted
        self._X: pd.DataFrame | None = None  # transient; not persisted
        self._provider: EstimatorProvider | None = None  # set by fit/tune
        self._block_values: npt.NDArray[Any] | None = None  # _prepare_training_data

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def fit(
        self,
        data: pd.DataFrame | None = None,
        params: dict[str, Any] | None = None,
    ) -> FitResult:
        """Run CV training and (optionally) full-data refit.

        Args:
            data: Training DataFrame.  Overrides any ``data`` passed at
                construction time and the ``data.path`` from config.
            params: Model parameters to override the config ``model.params``.

        Returns:
            The :class:`~lizyml.core.types.fit_result.FitResult` from CV.
        """
        cfg = self._cfg
        run_id = generate_run_id()

        self._ensure_run_dir(run_id)
        _log.info("event='fit.start' run_id=%s task=%s", run_id, cfg.task)

        # --- Load & prepare data ---------------------------------------------
        X, y, groups, components = self._prepare_training_data(data)
        self._X, self._y = X, y
        fingerprint = fp_compute(X, file_path=None)

        # --- Build components (H-0050/H-0053: provider-based) ----------------
        provider = get_provider(cfg.model)
        self._provider = provider
        run_meta = self._build_run_meta(run_id)
        model_params, smart_params = self._merge_params(provider)
        tc = self._build_train_components(
            X,
            y,
            provider=provider,
            model_params=model_params,
            smart_params=smart_params,
        )
        splitter = build_splitter(
            cfg,
            block_values=self._block_values,
            task=cfg.task,
            seed=cfg.training.seed,
        )
        n_classes = int(y.nunique()) if cfg.task == "multiclass" else None
        pipeline_factory = provider.build_pipeline_factory()

        # --- CV training -----------------------------------------------------
        cv_trainer = CVTrainer(
            outer_splitter=splitter,
            inner_valid=tc.inner_valid,
            pipeline_factory=pipeline_factory,
            estimator_factory=tc.estimator_factory,
            task=cfg.task,
            n_classes=n_classes,
            ratio_param_resolver=tc.ratio_resolver,
            collect_raw_scores=(cfg.calibration is not None),
        )
        time_values = components.time_col if components.time_col is not None else None
        fit_result = cv_trainer.fit(
            X,
            y,
            groups,
            data_fingerprint=fingerprint,
            run_meta=run_meta,
            sample_weight=tc.sample_weight,
            time_values=time_values,
        )

        # --- Calibration (binary only) ---------------------------------------
        fit_result = self._run_calibration(cfg, fit_result, y, groups)

        # --- Evaluation -------------------------------------------------------
        metric_names = cfg.evaluation.metrics or _DEFAULT_METRICS[cfg.task]
        evaluator = Evaluator(task=cfg.task)
        metrics = evaluator.evaluate(fit_result, y, metric_names)
        metrics = assemble_calibrated_metrics(
            fit_result, y, metric_names, evaluator, metrics
        )

        fit_result = dataclasses.replace(
            fit_result, metrics={**fit_result.metrics, **metrics}
        )
        self._metrics = metrics

        # --- Full-data refit (for predict) -----------------------------------
        refit_trainer = RefitTrainer(
            inner_valid=tc.inner_valid,
            pipeline_factory=pipeline_factory,
            estimator_factory=tc.estimator_factory,
            task=cfg.task,
            ratio_param_resolver=tc.ratio_resolver,
        )
        self._refit_result = refit_trainer.fit(X, y, groups)

        self._fit_result = fit_result
        _log.info("event='fit.done' run_id=%s", run_id)
        return fit_result

    def evaluate(
        self,
        metrics: list[str] | None = None,
    ) -> dict[str, Any]:
        """Return structured evaluation metrics from the last ``fit``.

        Args:
            metrics: Metric names to compute.  When ``None`` uses defaults
                or config-defined metrics (already computed during ``fit``).

        Returns:
            Structured dict: ``{"raw": {"oof": ..., "oof_per_fold": ...,
            "if_mean": ..., "if_per_fold": ...}}``.

        Raises:
            :class:`~lizyml.core.exceptions.LizyMLError` with
            ``MODEL_NOT_FIT`` when called before ``fit``.
        """
        self._require_fit()

        if self._metrics is None:
            raise LizyMLError(
                code=ErrorCode.MODEL_NOT_FIT,
                user_message="Metrics not computed. Call fit() first.",
                context={},
            )

        if metrics is None:
            return self._metrics

        # Validate task compatibility first (raises UNSUPPORTED_METRIC if invalid)
        from lizyml.metrics.registry import get_metrics_for_task

        get_metrics_for_task(metrics, self._cfg.task)  # raises on unknown/incompatible

        # Filter the pre-computed metrics dict to the requested subset
        return filter_metrics(self._metrics, set(metrics))

    def predict(
        self,
        X: pd.DataFrame,
        *,
        return_shap: bool = False,
    ) -> PredictionResult:
        """Generate predictions for new data.

        Uses the final model trained on the full dataset (via RefitTrainer).

        Args:
            X: Feature DataFrame with the same columns as training data.
            return_shap: When ``True``, compute SHAP values and populate
                ``PredictionResult.shap_values`` with shape
                ``(n_samples, n_features)``.  Requires ``shap`` to be
                installed (``pip install 'lizyml[explain]'``).

        Returns:
            :class:`~lizyml.core.types.predict_result.PredictionResult`.

        Raises:
            :class:`~lizyml.core.exceptions.LizyMLError` with
            ``MODEL_NOT_FIT`` when called before ``fit``.
            :class:`~lizyml.core.exceptions.LizyMLError` with
            ``OPTIONAL_DEP_MISSING`` when ``return_shap=True`` and shap
            is not installed.
        """
        fit = self._require_fit()
        refit = self._require_refit()

        # Restore pipeline from saved state via provider
        provider = get_provider(self._cfg.model)
        pipeline = provider.build_pipeline_factory()()
        pipeline.load_state(refit.pipeline_state)

        X_t, warnings = pipeline.transform_with_warnings(X)

        model = refit.model
        task = self._cfg.task

        pred: npt.NDArray[np.float64]
        proba: npt.NDArray[np.float64] | None = None

        if task == "regression":
            pred = model.predict(X_t)
        elif task == "binary":
            proba_2d = model.predict_proba(X_t)
            proba = proba_2d[:, 1]
            # Apply C_final calibrator when available (H-0030: raw score input)
            if fit.calibrator is not None:
                from lizyml.calibration.cross_fit import CalibrationResult

                if isinstance(fit.calibrator, CalibrationResult):
                    if fit.oof_raw_scores is not None:
                        raw_scores: npt.NDArray[np.float64] = model.predict_raw(X_t)
                        proba = fit.calibrator.c_final.predict(raw_scores)
                    else:
                        # Backward compat: old artifact trained on probabilities
                        proba = fit.calibrator.c_final.predict(proba)
            pred = (proba >= 0.5).astype(int)
        else:  # multiclass
            proba = model.predict_proba(X_t)
            pred = proba.argmax(axis=1)

        shap_values: npt.NDArray[np.float64] | None = None
        if return_shap:
            from lizyml.explain.shap_explainer import compute_shap_values

            shap_values = compute_shap_values(refit.model, X_t, task)

        return PredictionResult(
            pred=pred,
            proba=proba,
            shap_values=shap_values,
            used_features=refit.feature_names,
            warnings=warnings,
        )

    def tune(
        self,
        data: pd.DataFrame | None = None,
        *,
        progress_callback: TuneProgressCallback | None = None,
    ) -> TuningResult:
        """Run hyperparameter search with optuna.

        Requires ``tuning`` section in the config.  Best params are stored
        internally and used automatically in the next ``fit()`` call.

        Args:
            data: Training DataFrame.  Overrides any data from construction
                or ``data.path`` in config.
            progress_callback: Optional callback invoked after each trial.
                Receives a :class:`TuneProgressInfo` with current trial,
                total trials, elapsed time, best/latest score, and state.
                Exceptions raised inside the callback are caught and emitted
                as ``RuntimeWarning``; tuning is never aborted.

        Returns:
            :class:`~lizyml.core.types.tuning_result.TuningResult` with
            best params, best score, and full trial history.

        Raises:
            LizyMLError with CONFIG_INVALID when no ``tuning`` config is set.
            LizyMLError with OPTIONAL_DEP_MISSING when optuna is not installed.
            LizyMLError with TUNING_FAILED on study failure.
        """
        cfg = self._cfg
        if cfg.tuning is None:
            raise LizyMLError(
                code=ErrorCode.CONFIG_INVALID,
                user_message=(
                    "No tuning configuration found. "
                    "Add a 'tuning' section to the config to enable tuning."
                ),
                context={},
            )

        _log.info("event='tune.start' task=%s", cfg.task)

        self._ensure_run_dir(generate_run_id())
        X, y, groups, _ = self._prepare_training_data(data)
        self._X, self._y = X, y

        provider = get_provider(cfg.model)
        self._provider = provider
        n_classes = int(y.nunique()) if cfg.task == "multiclass" else None
        splitter = build_splitter(
            cfg,
            block_values=self._block_values,
            task=cfg.task,
            seed=cfg.training.seed,
        )
        base_model_params, base_smart_params = self._merge_params(provider)

        user_space = parse_space(cfg.tuning.optuna.space)
        if user_space:
            space = user_space
            fixed: dict[str, Any] = {}
        else:
            space = provider.default_space(cfg.task)
            fixed = provider.default_fixed_params(cfg.task)

        optuna_cfg = cfg.tuning.optuna.params
        metric_names = cfg.evaluation.metrics or _DEFAULT_METRICS[cfg.task]
        metric_name = metric_names[0]

        evaluator = Evaluator(task=cfg.task)
        fingerprint = fp_compute(X, file_path=None)
        run_meta = self._build_run_meta(generate_run_id())
        iv_factory = make_inner_valid_factory(cfg)

        # --- Build objective closure (H-0050: uses _build_train_components) ---
        def objective(trial: Any) -> float:
            trial_params = suggest_params(trial, space)
            model_p, smart_p, training_p = split_by_category(trial_params, space)

            # Merge: fixed + base config + trial model params
            merged_model = {**base_model_params, **fixed, **model_p}
            # Merge: base smart + trial smart params
            merged_smart = {**base_smart_params, **smart_p}

            # Handle training params (early_stopping, validation_ratio)
            inner_valid_override = None
            if "validation_ratio" in training_p:
                inner_valid_override = iv_factory(training_p["validation_ratio"])

            tc = self._build_train_components(
                X,
                y,
                provider=provider,
                model_params=merged_model,
                smart_params=merged_smart,
            )

            # Apply training param overrides to estimator (H-0054: use provider)
            estimator_factory = tc.estimator_factory
            if "early_stopping_rounds" in training_p:
                esr = int(training_p["early_stopping_rounds"])
                estimator_factory = provider.build_estimator_factory(
                    task=cfg.task,
                    params=merged_model,
                    n_classes=n_classes,
                    early_stopping_rounds=esr,
                    seed=cfg.training.seed,
                )

            cv_trainer = CVTrainer(
                outer_splitter=splitter,
                inner_valid=(
                    inner_valid_override
                    if inner_valid_override is not None
                    else tc.inner_valid
                ),
                pipeline_factory=provider.build_pipeline_factory(),
                estimator_factory=estimator_factory,
                task=cfg.task,
                n_classes=n_classes,
                ratio_param_resolver=tc.ratio_resolver,
            )
            fit_result = cv_trainer.fit(
                X,
                y,
                groups,
                data_fingerprint=fingerprint,
                run_meta=run_meta,
                sample_weight=tc.sample_weight,
            )
            metrics = evaluator.evaluate(fit_result, y, [metric_name])
            score: float = metrics["raw"]["oof"][metric_name]
            return score

        tuner = Tuner(
            dims=space,
            n_trials=optuna_cfg.n_trials,
            direction=optuna_cfg.direction,
            timeout=optuna_cfg.timeout,
            seed=cfg.training.seed,
            progress_callback=progress_callback,
        )

        result = tuner.tune(objective, metric_name=metric_name)
        self._tuning_result = result
        _log.info("event='tune.done' best_params=%s", result.best_params)
        return result

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def fit_result(self) -> FitResult:
        """Read-only access to the CV training result.

        Raises:
            LizyMLError with ``MODEL_NOT_FIT`` when ``fit()`` has not been called.
        """
        return self._require_fit()

    # ------------------------------------------------------------------
    # Internal helpers — TrainComponents (H-0050)
    # ------------------------------------------------------------------

    def _merge_params(
        self,
        provider: Any,
        override: dict[str, Any] | None = None,
    ) -> tuple[dict[str, Any], dict[str, Any]]:
        """Merge model and smart params with priority:
        Config defaults < tune best < fit() args.

        Args:
            provider: EstimatorProvider instance.
            override: Optional fit() arg overrides (highest priority).

        Returns:
            (model_params, smart_params) tuple.
        """
        cfg = self._cfg
        model_cfg = cfg.model

        # --- model_params / smart_params: Config defaults via provider ---
        model_params = provider.extract_model_params(model_cfg)
        smart_params = provider.extract_smart_params(model_cfg)

        # --- Overlay tune best ---
        if self._tuning_result is not None:
            model_params = {
                **model_params,
                **self._tuning_result.best_model_params,
            }
            if self._tuning_result.best_smart_params:
                smart_params = {
                    **smart_params,
                    **self._tuning_result.best_smart_params,
                }

        # --- Overlay fit() args (highest priority) ---
        if override:
            model_params = {**model_params, **override}

        return model_params, smart_params

    def _build_train_components(
        self,
        X: pd.DataFrame,
        y: pd.Series,
        *,
        provider: Any,
        model_params: dict[str, Any],
        smart_params: dict[str, Any],
    ) -> TrainComponents:
        """Build shared training components for CVTrainer and RefitTrainer.

        Delegates estimator-specific logic to the provider (H-0053).

        Args:
            X: Feature DataFrame.
            y: Target Series.
            provider: EstimatorProvider instance.
            model_params: Merged model params (from ``_merge_params``).
            smart_params: Merged smart params (from ``_merge_params``).

        Returns:
            :class:`TrainComponents` ready to pass to both trainers.
        """
        cfg = self._cfg
        n_classes = int(y.nunique()) if cfg.task == "multiclass" else None

        # --- Resolve smart params (Stage 1: data-size independent) ---
        sample_weight: npt.NDArray[np.float64] | None = None
        resolved_model = dict(model_params)

        if smart_params:
            smart_resolved, sample_weight = provider.resolve_smart_params(
                smart=smart_params,
                effective_params=resolved_model,
                n_rows=len(X),
                feature_names=list(X.columns),
                y=y,
                task=cfg.task,
            )
            resolved_model = {**resolved_model, **smart_resolved}

        # --- Build per-fold ratio resolver (Stage 2: n_rows dependent) ---
        ratio_resolver = provider.build_ratio_resolver(smart_params)

        # --- Build estimator factory ---
        estimator_factory = provider.build_estimator_factory(
            task=cfg.task,
            params=resolved_model,
            n_classes=n_classes,
            early_stopping_rounds=(
                cfg.training.early_stopping.rounds
                if cfg.training.early_stopping.enabled
                else None
            ),
            seed=cfg.training.seed,
        )

        # --- Inner validation ---
        inner_valid = build_inner_valid(cfg)

        return TrainComponents(
            estimator_factory=estimator_factory,
            sample_weight=sample_weight,
            ratio_resolver=ratio_resolver,
            inner_valid=inner_valid,
        )

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _load_data(self, data: pd.DataFrame | None) -> pd.DataFrame:
        """Resolve data from argument, constructor, or config path."""
        if data is not None:
            return data
        if self._data is not None:
            return self._data
        if self._cfg.data.path:
            return datasource.read(self._cfg.data.path)
        raise LizyMLError(
            code=ErrorCode.DATA_SCHEMA_INVALID,
            user_message=(
                "No data provided. Pass a DataFrame to fit(data=df) or "
                "set data.path in the config."
            ),
            context={},
        )

    def _prepare_training_data(
        self, data: pd.DataFrame | None
    ) -> tuple[pd.DataFrame, pd.Series, npt.NDArray[Any] | None, DataFrameComponents]:
        """Load data, build specs, and prepare X/y/groups for training.

        Handles time-series sorting when the split method requires it.
        """
        cfg = self._cfg
        self._block_values = None  # reset per-call transient state
        df = self._load_data(data)

        problem_spec = ProblemSpec(
            task=cfg.task,
            target=cfg.data.target,
            time_col=cfg.data.time_col,
            group_col=cfg.data.group_col,
            data_path=cfg.data.path,
        )
        feature_spec = FeatureSpec(
            exclude=tuple(cfg.features.exclude),
            auto_categorical=cfg.features.auto_categorical,
            categorical=tuple(cfg.features.categorical),
        )

        components = dataframe_builder.build(df, problem_spec, feature_spec)
        X, y = components.X, components.y
        groups: npt.NDArray[Any] | None = (
            components.group_col.to_numpy()
            if components.group_col is not None
            else None
        )

        if cfg.split.method in _TS_METHODS:
            if cfg.data.time_col is None:
                raise LizyMLError(
                    code=ErrorCode.CONFIG_INVALID,
                    user_message=(
                        f"split.method='{cfg.split.method}' requires "
                        "data.time_col to be set."
                    ),
                    context={"split_method": cfg.split.method},
                )
            tc = components.time_col
            assert tc is not None  # noqa: S101
            sort_order = tc.argsort()
            X = X.iloc[sort_order].reset_index(drop=True)
            y = y.iloc[sort_order].reset_index(drop=True)
            if groups is not None:
                groups = groups[sort_order]
            sorted_time = tc.iloc[sort_order].reset_index(drop=True)
            sorted_group = (
                components.group_col.iloc[sort_order].reset_index(drop=True)
                if components.group_col is not None
                else None
            )
            components = DataFrameComponents(
                X=X,
                y=y,
                time_col=sorted_time,
                group_col=sorted_group,
            )

        if cfg.split.method in _BLOCK_METHODS:
            if not isinstance(cfg.split, BlockedGroupKFoldConfig):
                raise LizyMLError(
                    code=ErrorCode.CONFIG_INVALID,
                    user_message=(
                        "Internal error: expected BlockedGroupKFoldConfig "
                        f"for method '{cfg.split.method}'."
                    ),
                    context={"split_method": cfg.split.method},
                )
            blocks_col_name = cfg.split.blocks.col
            groups_col_name = cfg.split.groups.col

            # Validate columns exist in the raw DataFrame (df)
            for col_name, label in [
                (blocks_col_name, "blocks.col"),
                (groups_col_name, "groups.col"),
            ]:
                if col_name not in df.columns:
                    raise LizyMLError(
                        code=ErrorCode.CONFIG_INVALID,
                        user_message=(
                            f"split.{label}='{col_name}' not found "
                            f"in DataFrame columns."
                        ),
                        context={
                            "column": col_name,
                            "available": list(df.columns),
                        },
                    )

            # Extract from raw df (before feature pipeline drops them)
            block_series = df[blocks_col_name]
            group_series = df[groups_col_name]

            # Sort by blocks.col
            sort_order = block_series.argsort()
            X = X.iloc[sort_order].reset_index(drop=True)
            y = y.iloc[sort_order].reset_index(drop=True)

            # Override groups with groups.col (not data.group_col)
            groups = group_series.iloc[sort_order].to_numpy()
            self._block_values = block_series.iloc[sort_order].to_numpy()

            # Update components
            block_sorted_time: pd.Series | None = (
                components.time_col.iloc[sort_order].reset_index(drop=True)
                if components.time_col is not None
                else None
            )
            block_sorted_group: pd.Series | None = (
                components.group_col.iloc[sort_order].reset_index(drop=True)
                if components.group_col is not None
                else None
            )
            components = DataFrameComponents(
                X=X, y=y, time_col=block_sorted_time, group_col=block_sorted_group
            )

        return X, y, groups, components

    def _build_run_meta(self, run_id: str) -> RunMeta:
        def _ver(pkg: str) -> str:
            try:
                return pkg_version(pkg)
            except Exception:
                return "unknown"

        # Common deps + estimator-specific deps via provider (H-0054)
        deps: dict[str, str] = {
            "pandas": _ver("pandas"),
            "numpy": _ver("numpy"),
            "scikit-learn": _ver("scikit-learn"),
        }
        if self._provider is not None:
            deps.update(self._provider.runtime_deps())

        return RunMeta(
            lizyml_version=__version__,
            python_version=sys.version,
            deps_versions=deps,
            config_normalized=self._cfg.model_dump(),
            config_version=self._cfg.config_version,
            run_id=run_id,
            timestamp=datetime.now(tz=timezone.utc).isoformat(),
        )

    def _ensure_run_dir(self, run_id: str) -> None:
        """Set up the output directory for a run if configured."""
        if self._output_dir is not None:
            from lizyml.core.logging import setup_output_dir

            self._run_dir = setup_output_dir(self._output_dir, run_id)

    def _run_calibration(
        self,
        cfg: LizyMLConfig,
        fit_result: FitResult,
        y: pd.Series,
        groups: npt.NDArray[Any] | None,
    ) -> FitResult:
        """Apply cross-fit calibration if configured. Returns updated FitResult."""
        if cfg.calibration is None:
            return fit_result

        if cfg.task != "binary":
            raise LizyMLError(
                code=ErrorCode.CALIBRATION_NOT_SUPPORTED,
                user_message=(
                    f"Calibration is only supported for binary classification. "
                    f"Got task='{cfg.task}'."
                ),
                context={"task": cfg.task},
            )

        from lizyml.calibration.cross_fit import cross_fit_calibrate
        from lizyml.calibration.registry import get_calibrator

        method = cfg.calibration.method
        cal_params = cfg.calibration.params or None
        # Use raw scores (logits) for calibration (H-0030)
        cal_scores = (
            fit_result.oof_raw_scores
            if fit_result.oof_raw_scores is not None
            else fit_result.oof_pred
        )
        y_arr = y.to_numpy()
        # Reuse outer CV splits for calibration cross-fit (H-0058).
        # Calibration input is (oof_scores, y) only — no X leakage.
        cal_split_indices = fit_result.splits.outer
        calibration_result = cross_fit_calibrate(
            oof_scores=cal_scores,
            y=y_arr,
            calibrator_factory=lambda: get_calibrator(method, params=cal_params),
            split_indices=cal_split_indices,
            oof_pred=fit_result.oof_pred,
        )
        new_splits = dataclasses.replace(
            fit_result.splits,
            calibration=cal_split_indices,
        )
        return dataclasses.replace(
            fit_result,
            calibrator=calibration_result,
            splits=new_splits,
        )

    def _require_fit(self) -> FitResult:
        if self._fit_result is None:
            raise LizyMLError(
                code=ErrorCode.MODEL_NOT_FIT,
                user_message="Model has not been fitted. Call fit() first.",
                context={},
            )
        return self._fit_result

    def _require_refit(self) -> RefitResult:
        if self._refit_result is None:
            raise LizyMLError(
                code=ErrorCode.MODEL_NOT_FIT,
                user_message="Model has not been fitted. Call fit() first.",
                context={},
            )
        return self._refit_result
