# SLAM_MAP.py
from __future__ import annotations

import json
import os
from dataclasses import dataclass
from pathlib import Path
import numpy as np


def load_slam_config() -> dict:
    import json, os
    from pathlib import Path

    cfg_path = os.environ.get("SLAM_CONFIG", "").strip()
    p = Path(cfg_path) if cfg_path else (Path(__file__).resolve().parent / "SLAM_Config.json")

    if not p.exists():
        raise FileNotFoundError(f"Missing config file: {p}")

    text = p.read_text(encoding="utf-8").strip()
    if not text:
        raise RuntimeError("SLAM_Config.json is empty.")

    return json.loads(text)


@dataclass
class MapConfig:
    display_voxel: float
    save_voxel: float
    data_folder: str
    save_extension: str

    @staticmethod
    def from_config_file() -> "MapConfig":
        cfg = load_slam_config()
        maps_dir = str(cfg["app"]["maps_dir"])
        return MapConfig(
            display_voxel=float(cfg["map"]["display_voxel"]),
            save_voxel=float(cfg["map"]["save_voxel"]),
            data_folder=maps_dir,
            save_extension=str(cfg["map"]["save_extension"]),
        )


def _voxel_downsample_numpy(points: np.ndarray, voxel: float) -> np.ndarray:
    if points is None or len(points) == 0:
        return np.zeros((0, 3), dtype=np.float32)
    if voxel <= 0:
        return points.astype(np.float32, copy=False)

    keys = np.floor(points / voxel).astype(np.int32)
    uniq, inv = np.unique(keys, axis=0, return_inverse=True)

    out = np.zeros((len(uniq), 3), dtype=np.float64)
    cnt = np.zeros((len(uniq),), dtype=np.int64)
    np.add.at(out, inv, points.astype(np.float64, copy=False))
    np.add.at(cnt, inv, 1)
    out /= np.maximum(cnt[:, None], 1)
    return out.astype(np.float32)


class StableMapLayer:
    def __init__(self, cfg: MapConfig | None = None):
        self.cfg = cfg if cfg is not None else MapConfig.from_config_file()
        Path(self.cfg.data_folder).mkdir(parents=True, exist_ok=True)
        self._points = np.zeros((0, 3), dtype=np.float32)

    def reset(self):
        self._points = np.zeros((0, 3), dtype=np.float32)

    def set_points(self, pts: np.ndarray):
        self._points = pts.astype(np.float32, copy=True) if pts is not None else np.zeros((0, 3), dtype=np.float32)

    def get_points(self) -> np.ndarray:
        return self._points

    def get_display_points(self) -> np.ndarray:
        return _voxel_downsample_numpy(self._points, float(self.cfg.display_voxel))

    def get_save_points(self) -> np.ndarray:
        return _voxel_downsample_numpy(self._points, float(self.cfg.save_voxel))

    def export_map(self, base_name: str) -> str:
        """
        Save stable map into maps_dir (no subfolders).
        """
        import open3d as o3d

        pts = self.get_save_points()
        if pts is None or len(pts) < 20:
            raise RuntimeError("Not enough stable points to save.")

        base = (base_name or "").strip() or "map_robot"
        ext = self.cfg.save_extension.strip() or ".ply"
        if base.endswith(ext):
            base = base[: -len(ext)]

        filename = f"{base}{ext}"
        n = 1
        folder = Path(self.cfg.data_folder)
        while (folder / filename).exists():
            filename = f"{base}({n}){ext}"
            n += 1

        full = str(folder / filename)
        pcd = o3d.geometry.PointCloud()
        pcd.points = o3d.utility.Vector3dVector(pts.astype(np.float64, copy=False))
        o3d.io.write_point_cloud(full, pcd)
        return full

    def load_map(self, filepath: str) -> np.ndarray:
        import open3d as o3d
        pcd = o3d.io.read_point_cloud(filepath)
        pts = np.asarray(pcd.points).astype(np.float32)
        self.set_points(pts)
        return pts
