from __future__ import annotations

from io import BytesIO
import math
import os
from pathlib import Path
from dataclasses import dataclass
import json
import logging
from typing import Any

from django.conf import settings
from django.core.files.base import ContentFile
from django.db import transaction

from .block_subdivision import detect_elbow_point, render_elbow_plot
from .models import (
    AnalysisGridObservation,
    BlockSubdivision,
    RemoteSensingClusterAssignment,
    RemoteSensingRun,
    RemoteSensingSubdivisionResult,
    SoilLocation,
)


DEFAULT_CLUSTER_FEATURES = [
    "ndvi",
    "ndwi",
    "soil_vv_db",
]
SUPPORTED_CLUSTER_FEATURES = tuple(DEFAULT_CLUSTER_FEATURES)
DEFAULT_RANDOM_STATE = 42
DEFAULT_MAX_K = 10
DEFAULT_REMOTE_SENSING_DIAGNOSTIC_DIR = "artifacts/remote_sensing_charts"

logger = logging.getLogger(__name__)


class DataDrivenSubdivisionError(Exception):
    """Raised when remote-sensing-driven subdivision can not be computed."""


class EmptyObservationDatasetError(DataDrivenSubdivisionError):
    """Raised when upstream persistence completes without usable clustering features."""


@dataclass
class ClusteringDataset:
    observations: list[AnalysisGridObservation]
    selected_features: list[str]
    raw_feature_rows: list[list[float | None]]
    raw_feature_maps: list[dict[str, float | None]]
    skipped_cell_codes: list[str]
    used_cell_codes: list[str]
    imputed_matrix: list[list[float]]
    scaled_matrix: list[list[float]]
    imputer_statistics: dict[str, float | None]
    scaler_means: dict[str, float]
    scaler_scales: dict[str, float]
    missing_value_counts: dict[str, int]
    skipped_reasons: dict[str, list[str]]


def create_remote_sensing_subdivision_result(
    *,
    location: SoilLocation,
    run: RemoteSensingRun,
    observations: list[AnalysisGridObservation],
    block_subdivision: BlockSubdivision | None = None,
    block_code: str = "",
    selected_features: list[str] | None = None,
    explicit_k: int | None = None,
    max_k: int = DEFAULT_MAX_K,
    random_state: int = DEFAULT_RANDOM_STATE,
) -> RemoteSensingSubdivisionResult:
    """
    Build a data-driven subdivision result from stored remote sensing observations.

    KMeans is applied on actual per-cell feature vectors, not geometric points.
    """
    dataset = build_clustering_dataset(
        observations=observations,
        selected_features=selected_features,
        run=run,
        location=location,
    )
    if not dataset.observations:
        raise DataDrivenSubdivisionError("هیچ observation قابل استفاده‌ای برای خوشه‌بندی باقی نماند.")

    optimal_k, inertia_curve = choose_cluster_count(
        scaled_matrix=dataset.scaled_matrix,
        explicit_k=explicit_k,
        max_k=max_k,
        random_state=random_state,
    )
    cluster_selection_strategy = "explicit_k" if explicit_k is not None else "elbow"
    labels = run_kmeans_labels(
        scaled_matrix=dataset.scaled_matrix,
        cluster_count=optimal_k,
        random_state=random_state,
    )
    cluster_summaries = build_cluster_summaries(
        observations=dataset.observations,
        labels=labels,
    )

    with transaction.atomic():
        result, _created = RemoteSensingSubdivisionResult.objects.update_or_create(
            run=run,
            defaults={
                "soil_location": location,
                "block_subdivision": block_subdivision,
                "block_code": block_code,
                "chunk_size_sqm": run.chunk_size_sqm,
                "temporal_start": run.temporal_start,
                "temporal_end": run.temporal_end,
                "cluster_count": optimal_k,
                "selected_features": dataset.selected_features,
                "skipped_cell_codes": dataset.skipped_cell_codes,
                "metadata": {
                    "cell_count": len(observations),
                    "used_cell_count": len(dataset.observations),
                    "skipped_cell_count": len(dataset.skipped_cell_codes),
                    "used_cell_codes": dataset.used_cell_codes,
                    "skipped_reasons": dataset.skipped_reasons,
                    "selected_features": dataset.selected_features,
                    "imputer_strategy": "median",
                    "imputer_statistics": dataset.imputer_statistics,
                    "missing_value_counts": dataset.missing_value_counts,
                    "scaler_means": dataset.scaler_means,
                    "scaler_scales": dataset.scaler_scales,
                    "kmeans_params": {
                        "random_state": random_state,
                        "explicit_k": explicit_k,
                        "selected_k": optimal_k,
                        "max_k": max_k,
                        "n_init": 10,
                        "selection_strategy": cluster_selection_strategy,
                    },
                    "inertia_curve": inertia_curve,
                    "cluster_summaries": cluster_summaries,
                },
            },
        )
        result.assignments.all().delete()
        assignment_rows = []
        for index, observation in enumerate(dataset.observations):
            assignment_rows.append(
                RemoteSensingClusterAssignment(
                    result=result,
                    cell=observation.cell,
                    cluster_label=int(labels[index]),
                    raw_feature_values=dataset.raw_feature_maps[index],
                    scaled_feature_values={
                        feature_name: round(dataset.scaled_matrix[index][feature_index], 6)
                        for feature_index, feature_name in enumerate(dataset.selected_features)
                    },
                )
            )
        RemoteSensingClusterAssignment.objects.bulk_create(assignment_rows)
        diagnostic_artifacts = _persist_remote_sensing_diagnostic_artifacts(
            result=result,
            observations=dataset.observations,
            labels=labels,
            cluster_summaries=cluster_summaries,
            selected_features=dataset.selected_features,
            scaled_matrix=dataset.scaled_matrix,
            inertia_curve=inertia_curve,
        )
        if diagnostic_artifacts:
            metadata = dict(result.metadata or {})
            metadata["diagnostic_artifacts"] = diagnostic_artifacts
            result.metadata = metadata
            result.save(update_fields=["metadata", "updated_at"])
        if block_subdivision is not None:
            sync_block_subdivision_with_result(
                block_subdivision=block_subdivision,
                result=result,
                observations=observations,
                cluster_summaries=cluster_summaries,
            )
        sync_location_block_layout_with_result(
            location=location,
            result=result,
            cluster_summaries=cluster_summaries,
        )
    return result


def build_clustering_dataset(
    *,
    observations: list[AnalysisGridObservation],
    selected_features: list[str] | None = None,
    run: RemoteSensingRun | None = None,
    location: SoilLocation | None = None,
) -> ClusteringDataset:
    selected_features = list(selected_features or DEFAULT_CLUSTER_FEATURES)
    invalid_features = [
        feature_name
        for feature_name in selected_features
        if feature_name not in SUPPORTED_CLUSTER_FEATURES
    ]
    if invalid_features:
        raise DataDrivenSubdivisionError(
            "ویژگی‌های نامعتبر برای خوشه‌بندی: "
            + ", ".join(sorted(invalid_features))
        )
    log_context = _build_clustering_log_context(
        observations=observations,
        selected_features=selected_features,
        run=run,
        location=location,
    )
    logger.info(
        "Preparing clustering dataset: %s",
        _serialize_log_payload(
            {
                **log_context,
                "total_observations": len(observations),
                "non_null_counts": _count_non_null_features(observations),
            }
        ),
    )
    raw_rows: list[list[float | None]] = []
    raw_maps: list[dict[str, float | None]] = []
    usable_observations: list[AnalysisGridObservation] = []
    skipped_cell_codes: list[str] = []
    used_cell_codes: list[str] = []
    missing_value_counts = {feature_name: 0 for feature_name in selected_features}
    skipped_reasons = {"all_features_missing": []}

    for observation in observations:
        feature_map = {
            feature_name: _coerce_float(getattr(observation, feature_name, None))
            for feature_name in selected_features
        }
        for feature_name, value in feature_map.items():
            if value is None:
                missing_value_counts[feature_name] += 1
        if all(value is None for value in feature_map.values()):
            logger.debug(
                "Skipping observation cell=%s: all clustering features are null | context=%s",
                observation.cell.cell_code,
                _serialize_log_payload(log_context),
            )
            skipped_cell_codes.append(observation.cell.cell_code)
            skipped_reasons["all_features_missing"].append(observation.cell.cell_code)
            continue
        usable_observations.append(observation)
        used_cell_codes.append(observation.cell.cell_code)
        raw_maps.append(feature_map)
        raw_rows.append([feature_map[feature_name] for feature_name in selected_features])

    logger.info(
        "Clustering dataset filtered observations: %s",
        _serialize_log_payload(
            {
                **log_context,
                "remaining_observations": len(usable_observations),
                "removed_observations": len(observations) - len(usable_observations),
            }
        )
    )

    zero_usable_feature_names = [
        feature_name for feature_name, missing_count in missing_value_counts.items() if missing_count == len(observations)
    ]
    if zero_usable_feature_names and len(zero_usable_feature_names) < len(selected_features):
        for feature_name in zero_usable_feature_names:
            logger.warning(
                "Feature %s has zero usable values in dataset | context=%s",
                feature_name,
                _serialize_log_payload(log_context),
            )

    if not usable_observations:
        error_context = {
            **log_context,
            "total_observations": len(observations),
            "removed_observations": len(observations),
            "null_counts_per_feature": missing_value_counts,
            "selected_features": selected_features,
        }
        logger.error(
            "No usable observations available for clustering: %s",
            _serialize_log_payload(error_context),
        )
        raise EmptyObservationDatasetError(
            "Upstream processing completed but no usable feature values were persisted."
        )

    try:
        import numpy as np
        from sklearn.impute import SimpleImputer
        from sklearn.preprocessing import StandardScaler
    except ImportError as exc:  # pragma: no cover - runtime dependency guard
        raise DataDrivenSubdivisionError(
            "scikit-learn و numpy برای خوشه‌بندی داده‌محور لازم هستند."
        ) from exc

    raw_matrix = np.array(raw_rows, dtype=float)
    imputer = SimpleImputer(strategy="median")
    imputed_matrix = imputer.fit_transform(raw_matrix)
    scaler = StandardScaler()
    scaled_matrix = scaler.fit_transform(imputed_matrix)

    return ClusteringDataset(
        observations=usable_observations,
        selected_features=selected_features,
        raw_feature_rows=raw_rows,
        raw_feature_maps=raw_maps,
        skipped_cell_codes=skipped_cell_codes,
        used_cell_codes=used_cell_codes,
        imputed_matrix=imputed_matrix.tolist(),
        scaled_matrix=scaled_matrix.tolist(),
        imputer_statistics={
            feature_name: _coerce_float(imputer.statistics_[index])
            for index, feature_name in enumerate(selected_features)
        },
        scaler_means={
            feature_name: float(scaler.mean_[index])
            for index, feature_name in enumerate(selected_features)
        },
        scaler_scales={
            feature_name: float(scaler.scale_[index] or 1.0)
            for index, feature_name in enumerate(selected_features)
        },
        missing_value_counts=missing_value_counts,
        skipped_reasons=skipped_reasons,
    )


def choose_cluster_count(
    *,
    scaled_matrix: list[list[float]],
    explicit_k: int | None,
    max_k: int,
    random_state: int,
) -> tuple[int, list[dict[str, float]]]:
    sample_count = len(scaled_matrix)
    if sample_count == 0:
        raise DataDrivenSubdivisionError("هیچ نمونه‌ای برای خوشه‌بندی وجود ندارد.")
    if sample_count == 1:
        return 1, [{"k": 1, "sse": 0.0}]

    if explicit_k is not None:
        if explicit_k <= 0:
            raise DataDrivenSubdivisionError("cluster_count باید بزرگ‌تر از صفر باشد.")
        return min(explicit_k, sample_count), []

    try:
        from sklearn.cluster import KMeans
    except ImportError as exc:  # pragma: no cover
        raise DataDrivenSubdivisionError("scikit-learn برای انتخاب تعداد خوشه لازم است.") from exc

    max_allowed_k = min(max_k, sample_count)
    inertia_curve = []
    for k in range(1, max_allowed_k + 1):
        model = KMeans(n_clusters=k, n_init=10, random_state=random_state)
        model.fit(scaled_matrix)
        inertia_curve.append({"k": k, "sse": round(float(model.inertia_), 6)})
    return detect_elbow_point(inertia_curve), inertia_curve


def run_kmeans_labels(
    *,
    scaled_matrix: list[list[float]],
    cluster_count: int,
    random_state: int,
) -> list[int]:
    if cluster_count <= 0:
        raise DataDrivenSubdivisionError("cluster_count باید بزرگ‌تر از صفر باشد.")
    if len(scaled_matrix) == 1:
        return [0]
    try:
        from sklearn.cluster import KMeans
    except ImportError as exc:  # pragma: no cover
        raise DataDrivenSubdivisionError("scikit-learn برای اجرای KMeans لازم است.") from exc
    model = KMeans(n_clusters=cluster_count, n_init=10, random_state=random_state)
    return [int(label) for label in model.fit_predict(scaled_matrix)]


def build_cluster_summaries(
    *,
    observations: list[AnalysisGridObservation],
    labels: list[int],
) -> list[dict[str, Any]]:
    clusters: dict[int, dict[str, Any]] = {}
    for observation, label in zip(observations, labels):
        cluster = clusters.setdefault(
            int(label),
            {
                "cluster_label": int(label),
                "cell_codes": [],
                "centroid_lat_sum": 0.0,
                "centroid_lon_sum": 0.0,
                "cell_count": 0,
            },
        )
        cluster["cell_codes"].append(observation.cell.cell_code)
        cluster["centroid_lat_sum"] += float(observation.cell.centroid_lat)
        cluster["centroid_lon_sum"] += float(observation.cell.centroid_lon)
        cluster["cell_count"] += 1

    summaries = []
    for cluster_label in sorted(clusters):
        cluster = clusters[cluster_label]
        cell_count = cluster["cell_count"] or 1
        summaries.append(
            {
                "cluster_label": cluster_label,
                "cell_count": cluster["cell_count"],
                "centroid_lat": round(cluster["centroid_lat_sum"] / cell_count, 6),
                "centroid_lon": round(cluster["centroid_lon_sum"] / cell_count, 6),
                "cell_codes": cluster["cell_codes"],
            }
        )
    return summaries


def sync_location_block_layout_with_result(
    *,
    location: SoilLocation,
    result: RemoteSensingSubdivisionResult,
    cluster_summaries: list[dict[str, Any]],
) -> None:
    layout = dict(location.block_layout or {})
    blocks = list(layout.get("blocks") or [])
    target_block = None
    for block in blocks:
        if block.get("block_code") == result.block_code:
            target_block = block
            break

    if target_block is None:
        target_block = {
            "block_code": result.block_code,
            "order": len(blocks) + 1,
            "source": "remote_sensing",
            "needs_subdivision": None,
            "sub_blocks": [],
        }
        blocks.append(target_block)

    target_block["needs_subdivision"] = result.cluster_count > 1
    target_block["sub_blocks"] = [
        {
            "sub_block_code": f"cluster-{cluster['cluster_label']}",
            "cluster_label": cluster["cluster_label"],
            "centroid_lat": cluster["centroid_lat"],
            "centroid_lon": cluster["centroid_lon"],
            "cell_count": cluster["cell_count"],
        }
        for cluster in cluster_summaries
    ]
    target_block["subdivision_summary"] = {
        "type": "data_driven_remote_sensing",
        "cluster_count": result.cluster_count,
        "selected_features": result.selected_features,
        "used_cell_count": result.metadata.get("used_cell_count", 0),
        "skipped_cell_count": result.metadata.get("skipped_cell_count", 0),
        "run_id": result.run_id,
    }
    layout["blocks"] = blocks
    layout["algorithm_status"] = "completed"
    location.block_layout = layout
    location.save(update_fields=["block_layout", "updated_at"])


def sync_block_subdivision_with_result(
    *,
    block_subdivision: BlockSubdivision,
    result: RemoteSensingSubdivisionResult,
    observations: list[AnalysisGridObservation],
    cluster_summaries: list[dict[str, Any]],
) -> None:
    metadata = dict(block_subdivision.metadata or {})
    metadata["data_driven_subdivision"] = {
        "run_id": result.run_id,
        "result_id": result.id,
        "cluster_count": result.cluster_count,
        "selected_features": result.selected_features,
        "used_cell_count": result.metadata.get("used_cell_count", 0),
        "skipped_cell_count": result.metadata.get("skipped_cell_count", 0),
        "temporal_extent": {
            "start_date": result.temporal_start.isoformat() if result.temporal_start else None,
            "end_date": result.temporal_end.isoformat() if result.temporal_end else None,
        },
        "inertia_curve": result.metadata.get("inertia_curve", []),
        "diagnostic_artifacts": result.metadata.get("diagnostic_artifacts", {}),
    }

    block_subdivision.grid_points = [
        {
            "cell_code": observation.cell.cell_code,
            "centroid_lat": round(float(observation.cell.centroid_lat), 6),
            "centroid_lon": round(float(observation.cell.centroid_lon), 6),
        }
        for observation in observations
    ]
    block_subdivision.centroid_points = [
        {
            "sub_block_code": f"cluster-{cluster['cluster_label']}",
            "cluster_label": cluster["cluster_label"],
            "centroid_lat": cluster["centroid_lat"],
            "centroid_lon": cluster["centroid_lon"],
            "cell_count": cluster["cell_count"],
            "cell_codes": cluster["cell_codes"],
        }
        for cluster in cluster_summaries
    ]
    block_subdivision.grid_point_count = len(observations)
    block_subdivision.centroid_count = len(cluster_summaries)
    block_subdivision.status = "subdivided"
    block_subdivision.metadata = metadata

    plot_content = render_elbow_plot(
        inertia_curve=result.metadata.get("inertia_curve", []),
        optimal_k=result.cluster_count,
        block_code=result.block_code or block_subdivision.block_code,
    )
    if plot_content is not None:
        block_subdivision.elbow_plot.save(
            f"remote-sensing-{result.soil_location_id}-{result.block_code or block_subdivision.block_code}-elbow.png",
            plot_content,
            save=False,
        )
        block_subdivision.save(
            update_fields=[
                "grid_points",
                "centroid_points",
                "grid_point_count",
                "centroid_count",
                "status",
                "metadata",
                "elbow_plot",
                "updated_at",
            ]
        )
        return

    block_subdivision.save(
        update_fields=[
            "grid_points",
            "centroid_points",
            "grid_point_count",
            "centroid_count",
            "status",
            "metadata",
            "updated_at",
        ]
    )


def _coerce_float(value: Any) -> float | None:
    if value is None:
        return None
    try:
        return float(value)
    except (TypeError, ValueError):
        return None


def _count_non_null_features(observations: list[AnalysisGridObservation]) -> dict[str, int]:
    counts = {feature_name: 0 for feature_name in DEFAULT_CLUSTER_FEATURES}
    for observation in observations:
        for feature_name in DEFAULT_CLUSTER_FEATURES:
            if _coerce_float(getattr(observation, feature_name, None)) is not None:
                counts[feature_name] += 1
    return counts


def _persist_remote_sensing_diagnostic_artifacts(
    *,
    result: RemoteSensingSubdivisionResult,
    observations: list[AnalysisGridObservation],
    labels: list[int],
    cluster_summaries: list[dict[str, Any]],
    selected_features: list[str],
    scaled_matrix: list[list[float]],
    inertia_curve: list[dict[str, float]],
) -> dict[str, Any]:
    try:
        artifact_dir = _build_remote_sensing_diagnostic_dir(result=result)
        artifact_dir.mkdir(parents=True, exist_ok=True)

        specs = [
            (
                "elbow_plot",
                render_elbow_plot(
                    inertia_curve=inertia_curve,
                    optimal_k=result.cluster_count,
                    block_code=result.block_code or "farm",
                ),
                "elbow",
            ),
            (
                "cluster_map",
                _render_cluster_map_plot(
                    observations=observations,
                    labels=labels,
                    block_code=result.block_code or "farm",
                ),
                "cluster-map",
            ),
            (
                "cluster_sizes",
                _render_cluster_size_plot(
                    cluster_summaries=cluster_summaries,
                    block_code=result.block_code or "farm",
                ),
                "cluster-sizes",
            ),
            (
                "feature_pairs",
                _render_feature_pair_plot(
                    selected_features=selected_features,
                    scaled_matrix=scaled_matrix,
                    labels=labels,
                    block_code=result.block_code or "farm",
                ),
                "feature-pairs",
            ),
        ]

        files: dict[str, str] = {}
        for artifact_key, content, suffix in specs:
            if content is None:
                continue
            target_path = artifact_dir / f"{_build_remote_sensing_artifact_stem(result=result)}__{suffix}.png"
            _write_content_file(target_path=target_path, content=content)
            files[artifact_key] = _to_project_relative_path(target_path)

        return {
            "directory": _to_project_relative_path(artifact_dir),
            "files": files,
        }
    except (DataDrivenSubdivisionError, OSError) as exc:
        logger.warning(
            "Failed to persist remote sensing diagnostic artifacts for result_id=%s: %s",
            result.id,
            exc,
        )
        return {}


def _build_remote_sensing_diagnostic_dir(*, result: RemoteSensingSubdivisionResult) -> Path:
    configured_dir = str(
        os.environ.get("REMOTE_SENSING_DIAGNOSTIC_DIR", DEFAULT_REMOTE_SENSING_DIAGNOSTIC_DIR)
    ).strip()
    base_dir = Path(getattr(settings, "BASE_DIR", Path.cwd()))
    target_dir = Path(configured_dir)
    if not target_dir.is_absolute():
        target_dir = base_dir / target_dir
    block_component = _sanitize_path_component(result.block_code or "farm")
    return target_dir / f"location-{result.soil_location_id}" / f"run-{result.run_id}-{block_component}"


def _build_remote_sensing_artifact_stem(*, result: RemoteSensingSubdivisionResult) -> str:
    return (
        f"location-{result.soil_location_id}"
        f"__run-{result.run_id}"
        f"__{_sanitize_path_component(result.block_code or 'farm')}"
    )


def _write_content_file(*, target_path: Path, content: ContentFile) -> None:
    target_path.parent.mkdir(parents=True, exist_ok=True)
    content.open("rb")
    try:
        target_path.write_bytes(content.read())
    finally:
        content.close()


def _to_project_relative_path(path: Path) -> str:
    base_dir = Path(getattr(settings, "BASE_DIR", Path.cwd()))
    try:
        return str(path.relative_to(base_dir))
    except ValueError:
        return str(path)


def _sanitize_path_component(value: str) -> str:
    text = str(value or "").strip() or "unknown"
    sanitized = "".join(character if character.isalnum() or character in {"-", "_", "."} else "_" for character in text)
    return sanitized or "unknown"


def _render_cluster_map_plot(
    *,
    observations: list[AnalysisGridObservation],
    labels: list[int],
    block_code: str,
) -> ContentFile | None:
    if not observations:
        return None
    plt = _import_matplotlib_pyplot()
    unique_labels = sorted(set(int(label) for label in labels))
    colors = plt.cm.get_cmap("tab10", max(len(unique_labels), 1))
    fig, ax = plt.subplots(figsize=(8, 6))
    buffer = BytesIO()
    try:
        for color_index, cluster_label in enumerate(unique_labels):
            cluster_points = [
                (float(observation.cell.centroid_lon), float(observation.cell.centroid_lat))
                for observation, label in zip(observations, labels)
                if int(label) == cluster_label
            ]
            if not cluster_points:
                continue
            xs = [point[0] for point in cluster_points]
            ys = [point[1] for point in cluster_points]
            ax.scatter(
                xs,
                ys,
                s=70,
                alpha=0.9,
                color=colors(color_index),
                edgecolors="white",
                linewidths=0.8,
                label=f"Cluster {cluster_label}",
            )
        ax.set_title(f"KMeans Spatial Cluster Map - {block_code}")
        ax.set_xlabel("Longitude")
        ax.set_ylabel("Latitude")
        ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)
        if unique_labels:
            ax.legend()
        fig.tight_layout()
        fig.savefig(buffer, format="png", dpi=150)
        buffer.seek(0)
        return ContentFile(buffer.getvalue())
    finally:
        buffer.close()
        plt.close(fig)


def _render_cluster_size_plot(
    *,
    cluster_summaries: list[dict[str, Any]],
    block_code: str,
) -> ContentFile | None:
    if not cluster_summaries:
        return None
    plt = _import_matplotlib_pyplot()
    labels = [f"C{int(cluster['cluster_label'])}" for cluster in cluster_summaries]
    counts = [int(cluster["cell_count"]) for cluster in cluster_summaries]
    fig, ax = plt.subplots(figsize=(8, 5))
    buffer = BytesIO()
    try:
        bars = ax.bar(labels, counts, color="#2f6fed", alpha=0.85)
        for bar, count in zip(bars, counts):
            ax.text(
                bar.get_x() + bar.get_width() / 2.0,
                bar.get_height(),
                str(count),
                ha="center",
                va="bottom",
                fontsize=9,
            )
        ax.set_title(f"Cluster Sizes - {block_code}")
        ax.set_xlabel("Cluster")
        ax.set_ylabel("Cell Count")
        ax.grid(True, axis="y", linestyle="--", linewidth=0.5, alpha=0.4)
        fig.tight_layout()
        fig.savefig(buffer, format="png", dpi=150)
        buffer.seek(0)
        return ContentFile(buffer.getvalue())
    finally:
        buffer.close()
        plt.close(fig)


def _render_feature_pair_plot(
    *,
    selected_features: list[str],
    scaled_matrix: list[list[float]],
    labels: list[int],
    block_code: str,
) -> ContentFile | None:
    if not scaled_matrix or not selected_features:
        return None
    plt = _import_matplotlib_pyplot()
    feature_count = len(selected_features)
    pair_indexes = [(0, 0)] if feature_count == 1 else [
        (left_index, right_index)
        for left_index in range(feature_count)
        for right_index in range(left_index + 1, feature_count)
    ]
    subplot_count = len(pair_indexes)
    columns = 2 if subplot_count > 1 else 1
    rows = math.ceil(subplot_count / columns)
    fig, axes = plt.subplots(rows, columns, figsize=(7 * columns, 5 * rows))
    axes_list = axes.flatten().tolist() if hasattr(axes, "flatten") else [axes]
    unique_labels = sorted(set(int(label) for label in labels))
    colors = plt.cm.get_cmap("tab10", max(len(unique_labels), 1))
    buffer = BytesIO()
    try:
        for axis, (left_index, right_index) in zip(axes_list, pair_indexes):
            if feature_count == 1:
                xs = list(range(1, len(scaled_matrix) + 1))
                ys = [row[0] for row in scaled_matrix]
                for color_index, cluster_label in enumerate(unique_labels):
                    filtered = [
                        (x_value, y_value)
                        for x_value, y_value, label in zip(xs, ys, labels)
                        if int(label) == cluster_label
                    ]
                    axis.scatter(
                        [item[0] for item in filtered],
                        [item[1] for item in filtered],
                        s=55,
                        color=colors(color_index),
                        alpha=0.85,
                        label=f"Cluster {cluster_label}",
                    )
                axis.set_xlabel("Observation Index")
                axis.set_ylabel(f"{selected_features[0]} (scaled)")
                axis.set_title(f"{selected_features[0]} distribution")
            else:
                x_values = [row[left_index] for row in scaled_matrix]
                y_values = [row[right_index] for row in scaled_matrix]
                for color_index, cluster_label in enumerate(unique_labels):
                    filtered = [
                        (x_value, y_value)
                        for x_value, y_value, label in zip(x_values, y_values, labels)
                        if int(label) == cluster_label
                    ]
                    axis.scatter(
                        [item[0] for item in filtered],
                        [item[1] for item in filtered],
                        s=55,
                        color=colors(color_index),
                        alpha=0.85,
                        label=f"Cluster {cluster_label}",
                    )
                axis.set_xlabel(f"{selected_features[left_index]} (scaled)")
                axis.set_ylabel(f"{selected_features[right_index]} (scaled)")
                axis.set_title(
                    f"{selected_features[left_index]} vs {selected_features[right_index]}"
                )
            axis.grid(True, linestyle="--", linewidth=0.5, alpha=0.4)

        for axis in axes_list[subplot_count:]:
            axis.remove()

        if unique_labels and axes_list:
            axes_list[0].legend()
        fig.suptitle(f"KMeans Feature Diagnostics - {block_code}", fontsize=14)
        fig.tight_layout(rect=(0, 0, 1, 0.97))
        fig.savefig(buffer, format="png", dpi=150)
        buffer.seek(0)
        return ContentFile(buffer.getvalue())
    finally:
        buffer.close()
        plt.close(fig)


def _import_matplotlib_pyplot():
    try:
        import matplotlib

        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
    except ImportError as exc:  # pragma: no cover - runtime dependency guard
        raise DataDrivenSubdivisionError("matplotlib برای ذخیره نمودارهای KMeans لازم است.") from exc
    return plt


def _build_clustering_log_context(
    *,
    observations: list[AnalysisGridObservation],
    selected_features: list[str],
    run: RemoteSensingRun | None,
    location: SoilLocation | None,
) -> dict[str, Any]:
    first_observation = observations[0] if observations else None
    observation_metadata = dict(getattr(first_observation, "metadata", {}) or {})
    resolved_run = run or getattr(first_observation, "run", None)
    resolved_location = location or getattr(getattr(first_observation, "cell", None), "soil_location", None)
    temporal_start = getattr(resolved_run, "temporal_start", None) or getattr(first_observation, "temporal_start", None)
    temporal_end = getattr(resolved_run, "temporal_end", None) or getattr(first_observation, "temporal_end", None)
    return {
        "run_id": getattr(resolved_run, "id", None),
        "job_ref": observation_metadata.get("job_refs", {}),
        "region_id": getattr(resolved_location, "id", None),
        "date_range": {
            "temporal_start": temporal_start.isoformat() if hasattr(temporal_start, "isoformat") else temporal_start,
            "temporal_end": temporal_end.isoformat() if hasattr(temporal_end, "isoformat") else temporal_end,
        },
        "selected_features": selected_features,
    }


def _serialize_log_payload(payload: dict[str, Any]) -> str:
    return json.dumps(payload, ensure_ascii=True, default=str, sort_keys=True)
