"""
marcus_autonomous.py — Autonomous Exploration Mode
====================================================
Project  : Marcus | YS Lootah Technology
Hardware : Unitree G1 EDU + Jetson Orin NX

Marcus moves freely through the office, identifies areas and objects,
builds a live map of what it sees, and saves everything to a session folder.

Commands
--------
  auto on        — start autonomous exploration
  auto off       — stop autonomous exploration
  auto status    — show current exploration state
  auto save      — save current map snapshot
  auto summary   — LLaVA summary of what was explored

How it works
------------
  1. Marcus moves forward, scanning with YOLO every 0.4s
  2. Every N steps: LLaVA assesses the scene (area type, objects, notes)
  3. Odometry records position at each observation
  4. All data saved to map/map_XXX_DATE/ folder
  5. Robot avoids walls by turning when blocked

Files saved
-----------
  Data/Brain/maps/map_001_YYYY-MM-DD/
    observations.json   — [{step, time, x, y, area_type, objects, observation}]
    path.json           — [{x, y, heading, t}] — full path walked
    summary.txt         — auto-generated LLaVA summary
    frames/             — camera captures at interesting points

Import in marcus_brain.py
-------------------------
  from marcus_autonomous import AutonomousMode
  auto = AutonomousMode(
      get_frame_fn=get_frame,
      send_vel_fn=send_vel,
      gradual_stop_fn=gradual_stop,
      yolo_sees_fn=yolo_sees,
      yolo_summary_fn=yolo_summary,
      yolo_all_classes_fn=yolo_all_classes,
      yolo_closest_fn=yolo_closest,
      odom_fn=_odom_pos,
      call_llava_fn=_call_llava,
      patrol_prompt=PATROL_PROMPT,
      mem=mem,
      models_dir=MODELS_DIR,
  )
  auto.enable()    # start exploration
  auto.disable()   # stop exploration
  auto.status()    # print current state
"""

import json
import time
import threading
import os
import base64
from datetime import datetime
from pathlib import Path


# ══════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════

LLAVA_EVERY_N_STEPS  = 5      # call LLaVA every N movement steps (saves GPU)
YOLO_CHECK_INTERVAL  = 0.4    # seconds between YOLO checks
FORWARD_DURATION     = 1.5    # seconds per forward step
TURN_DURATION        = 2.0    # seconds to turn when obstacle detected
PERSON_STOP_DIST     = 0.3    # stop if person closer than this (relative)
MAX_OBSERVATIONS     = 500    # max observations before auto-stop
SAVE_FRAMES          = True   # save camera frames at interesting points
INTERESTING_COOLDOWN = 5.0    # seconds between saving "interesting" frames


class AutonomousMode:
    """
    Autonomous office exploration.

    Thread-safe. enable() starts a background thread. disable() stops it.
    All observations saved to disk automatically.

    State machine:
      IDLE → EXPLORING → IDLE
      Any state → STOPPING → IDLE (on disable())
    """

    def __init__(self, get_frame_fn, send_vel_fn, gradual_stop_fn,
                 yolo_sees_fn, yolo_summary_fn, yolo_all_classes_fn,
                 yolo_closest_fn, odom_fn, call_llava_fn,
                 patrol_prompt: str, mem=None, models_dir: str = None):

        self._get_frame       = get_frame_fn
        self._send_vel        = send_vel_fn
        self._gradual_stop    = gradual_stop_fn
        self._yolo_sees       = yolo_sees_fn
        self._yolo_summary    = yolo_summary_fn
        self._yolo_all_classes = yolo_all_classes_fn
        self._yolo_closest    = yolo_closest_fn
        self._odom_pos        = odom_fn
        self._call_llava      = call_llava_fn
        self._patrol_prompt   = patrol_prompt
        self._mem             = mem
        if models_dir is None:
            models_dir = str(Path(__file__).resolve().parent.parent.parent / "Data" / "Brain")
        self._models_dir      = Path(models_dir)

        # State
        self._enabled     = False
        self._thread      = None
        self._lock        = threading.Lock()

        # Current exploration session
        self._map_dir         = None
        self._observations    = []
        self._path            = []
        self._step            = 0
        self._start_time      = None
        self._last_interesting = 0.0

        # Turn tracking — alternate left/right when blocked
        self._last_turn = "left"

        # Stats
        self._area_counts = {}   # {area_type: count}
        self._all_objects = set()

    # ── PUBLIC API ─────────────────────────────────────────────────────────────

    def enable(self):
        """Start autonomous exploration."""
        with self._lock:
            if self._enabled:
                print("  [Auto] Already running — use 'auto off' to stop first")
                return

            self._enabled    = True
            self._step       = 0
            self._start_time = time.time()
            self._observations = []
            self._path         = []
            self._area_counts  = {}
            self._all_objects  = set()
            self._map_dir      = self._create_map_dir()

            self._thread = threading.Thread(
                target=self._explore_loop,
                daemon=True,
                name="auto-explore"
            )
            self._thread.start()

        print(f"\n  [Auto] Exploration started")
        print(f"  [Auto] Map folder: {self._map_dir}")
        print(f"  [Auto] Type 'auto off' to stop\n")

    def disable(self):
        """Stop autonomous exploration and save results."""
        with self._lock:
            if not self._enabled:
                print("  [Auto] Not running")
                return
            self._enabled = False

        print("\n  [Auto] Stopping exploration...")

        # Wait for thread to finish
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=5.0)

        self._gradual_stop()
        self._save_session()
        self._print_summary()

    def status(self):
        """Print current exploration state."""
        with self._lock:
            running = self._enabled
            step    = self._step
            obs     = len(self._observations)

        if not running:
            print("  [Auto] Status: IDLE")
            if self._map_dir:
                print(f"  [Auto] Last map: {self._map_dir}")
            return

        elapsed = time.time() - (self._start_time or time.time())
        mins = int(elapsed // 60)
        secs = int(elapsed % 60)

        print(f"  [Auto] Status: EXPLORING")
        print(f"  [Auto] Duration: {mins}m {secs}s")
        print(f"  [Auto] Steps: {step} | Observations: {obs}")

        if self._area_counts:
            areas = ", ".join(f"{k}:{v}" for k, v in sorted(self._area_counts.items()))
            print(f"  [Auto] Areas seen: {areas}")

        if self._all_objects:
            print(f"  [Auto] Objects found: {', '.join(sorted(self._all_objects))}")

        pos = self._odom_pos()
        if pos:
            print(f"  [Auto] Position: x={pos['x']:.2f} y={pos['y']:.2f} heading={pos['heading']:.1f}°")

    def is_enabled(self) -> bool:
        with self._lock:
            return self._enabled

    def save_snapshot(self):
        """Save current state to disk without stopping."""
        self._save_observations()
        self._save_path()
        print(f"  [Auto] Snapshot saved to {self._map_dir}")

    # ── EXPLORATION LOOP ────────────────────────────────────────────────────────

    def _explore_loop(self):
        """
        Main autonomous exploration loop.

        Steps:
          1. Check for person in path (safety stop)
          2. Check YOLO for interesting objects
          3. Move forward
          4. Every N steps: call LLaVA for scene assessment
          5. On obstacle: turn and continue
          6. Log position + observations
        """
        consecutive_blocks = 0   # count consecutive blocked steps

        while self._enabled:
            self._step += 1

            # ── Safety: stop if person too close ─────────────────────────────
            if self._yolo_sees("person"):
                closest = self._yolo_closest("person")
                if closest and closest.distance_estimate == "very close":
                    print(f"  [Auto] Person very close — pausing 2s")
                    self._gradual_stop()
                    time.sleep(2.0)
                    continue

            # ── Record YOLO detections ────────────────────────────────────────
            detected_classes = self._yolo_all_classes()
            for cls in detected_classes:
                self._all_objects.add(cls)

            # ── Record odometry path point ────────────────────────────────────
            pos = self._odom_pos()
            if pos:
                self._path.append({
                    "step": self._step,
                    "t":    round(time.time() - self._start_time, 1),
                    "x":    pos["x"],
                    "y":    pos["y"],
                    "h":    pos["heading"],
                })

            # ── LLaVA scene assessment every N steps ─────────────────────────
            if self._step % LLAVA_EVERY_N_STEPS == 0:
                self._assess_scene(pos, detected_classes)

            # ── Movement decision ─────────────────────────────────────────────
            if consecutive_blocks >= 3:
                # Stuck — turn more aggressively
                print(f"  [Auto] Stuck — turning {self._last_turn} 180°")
                self._turn(self._last_turn, TURN_DURATION * 2)
                consecutive_blocks = 0
                continue

            # Move forward
            blocked = self._move_forward()

            if blocked:
                consecutive_blocks += 1
                # Alternate left/right turns to explore both directions
                turn_dir = "left" if self._last_turn == "right" else "right"
                self._last_turn = turn_dir
                print(f"  [Auto] Obstacle — turning {turn_dir}")
                self._turn(turn_dir, TURN_DURATION)
            else:
                consecutive_blocks = 0

            # ── Max observations check ────────────────────────────────────────
            if len(self._observations) >= MAX_OBSERVATIONS:
                print(f"  [Auto] Max observations ({MAX_OBSERVATIONS}) reached — stopping")
                self._enabled = False
                break

            # No trailing sleep — _move_forward() takes FORWARD_DURATION,
            # _turn() takes TURN_DURATION, and LLaVA assessment is ~1-2s.
            # The body always consumes real wall time, so an extra sleep here
            # would be pure dead time.

        # Clean up
        self._gradual_stop()

    def _move_forward(self) -> bool:
        """
        Move forward for FORWARD_DURATION seconds.

        Returns True if blocked. Obstacle detection is not wired in here —
        LiDAR runs at the brain layer (API.lidar_api.obstacle_ahead) and
        isn't passed into AutonomousMode today. Extend __init__ with an
        optional `obstacle_fn=` hook if you want this loop to stop on
        obstacles detected outside YOLO's view.
        """
        t0 = time.time()
        while time.time() - t0 < FORWARD_DURATION and self._enabled:
            self._send_vel(vx=0.25)
            time.sleep(0.05)
        self._send_vel(0, 0, 0)
        time.sleep(0.1)
        return False

    def _turn(self, direction: str, duration: float):
        """Turn left or right for given duration."""
        vyaw = 0.25 if direction == "left" else -0.25
        t0 = time.time()
        while time.time() - t0 < duration and self._enabled:
            self._send_vel(vyaw=vyaw)
            time.sleep(0.05)
        self._send_vel(0, 0, 0)
        time.sleep(0.2)

    def _assess_scene(self, pos: dict, yolo_classes: set):
        """
        Call LLaVA to understand the current scene.
        Saves observation + optionally a camera frame.
        """
        img = self._get_frame()
        if img is None:
            return

        try:
            raw = self._call_llava(self._patrol_prompt, img, num_predict=120)
            raw_clean = raw.replace("```json", "").replace("```", "").strip()
            s = raw_clean.find("{")
            e = raw_clean.rfind("}") + 1
            d = json.loads(raw_clean[s:e]) if s != -1 and e > 0 else None

            if d is None:
                return

            area_type   = d.get("area_type", "unknown")
            observation = d.get("observation", "")
            objects     = d.get("objects", [])
            interesting = d.get("interesting", False)

            # Update area counts
            self._area_counts[area_type] = self._area_counts.get(area_type, 0) + 1

            # Add objects to global set
            for obj in objects:
                self._all_objects.add(obj)

            # Build observation record
            obs = {
                "step":        self._step,
                "time":        round(time.time() - self._start_time, 1),
                "timestamp":   datetime.now().strftime("%H:%M:%S"),
                "x":           pos["x"] if pos else None,
                "y":           pos["y"] if pos else None,
                "heading":     pos["heading"] if pos else None,
                "area_type":   area_type,
                "objects":     objects,
                "yolo_classes": list(yolo_classes),
                "observation": observation,
                "interesting": interesting,
            }
            self._observations.append(obs)

            print(f"  [Auto] Step {self._step} | {area_type} | {observation[:60]}")
            if objects:
                print(f"  [Auto] Objects: {', '.join(objects)}")

            # Save frame if interesting
            if interesting and SAVE_FRAMES:
                now = time.time()
                if now - self._last_interesting > INTERESTING_COOLDOWN:
                    self._save_frame(img, self._step)
                    self._last_interesting = now

            # Log to session memory
            if self._mem:
                self._mem.log_detection(area_type, "center", "medium")

            # Auto-flush observations every 20 steps
            if self._step % 20 == 0:
                self._save_observations()
                self._save_path()

        except Exception as e:
            print(f"  [Auto] LLaVA assess error: {e}")

    # ── FILE I/O ────────────────────────────────────────────────────────────────

    def _create_map_dir(self) -> Path:
        """Create a new map folder with incremented ID."""
        maps_dir = self._models_dir / "map"
        maps_dir.mkdir(parents=True, exist_ok=True)

        existing = [d for d in maps_dir.iterdir() if d.is_dir() and d.name.startswith("map_")]
        nums = []
        for d in existing:
            parts = d.name.split("_")
            if len(parts) >= 2 and parts[1].isdigit():
                nums.append(int(parts[1]))

        next_num = max(nums) + 1 if nums else 1
        date_str = datetime.now().strftime("%Y-%m-%d")
        map_dir  = maps_dir / f"map_{next_num:03d}_{date_str}"
        map_dir.mkdir(parents=True, exist_ok=True)
        (map_dir / "frames").mkdir(exist_ok=True)
        return map_dir

    def _save_observations(self):
        if not self._map_dir or not self._observations:
            return
        try:
            path = self._map_dir / "observations.json"
            tmp  = path.with_suffix(".tmp")
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(self._observations, f, indent=2, ensure_ascii=False)
            tmp.replace(path)
        except Exception as e:
            print(f"  [Auto] Save observations error: {e}")

    def _save_path(self):
        if not self._map_dir or not self._path:
            return
        try:
            path = self._map_dir / "path.json"
            tmp  = path.with_suffix(".tmp")
            with open(tmp, "w", encoding="utf-8") as f:
                json.dump(self._path, f, indent=2)
            tmp.replace(path)
        except Exception as e:
            print(f"  [Auto] Save path error: {e}")

    def _save_frame(self, img_b64: str, step: int):
        """Save a camera frame as JPEG."""
        if not self._map_dir or not img_b64:
            return
        try:
            frame_path = self._map_dir / "frames" / f"frame_{step:04d}.jpg"
            with open(frame_path, "wb") as f:
                f.write(__import__("base64").b64decode(img_b64))
        except Exception as e:
            print(f"  [Auto] Save frame error: {e}")

    def _generate_summary(self) -> str:
        """Generate a text summary of the exploration session."""
        elapsed = time.time() - (self._start_time or time.time())
        mins = int(elapsed // 60)
        secs = int(elapsed % 60)

        lines = [
            f"Autonomous Exploration Summary",
            f"==============================",
            f"Map: {self._map_dir.name if self._map_dir else 'unknown'}",
            f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
            f"Duration: {mins}m {secs}s",
            f"Steps taken: {self._step}",
            f"Observations: {len(self._observations)}",
            f"",
            f"Areas identified:",
        ]
        for area, count in sorted(self._area_counts.items(), key=lambda x: -x[1]):
            lines.append(f"  {area:<20} {count} observations")

        lines.append("")
        lines.append(f"Objects detected:")
        for obj in sorted(self._all_objects):
            lines.append(f"  - {obj}")

        # Add notable observations
        interesting = [o for o in self._observations if o.get("interesting")]
        if interesting:
            lines.append("")
            lines.append(f"Notable observations ({len(interesting)}):")
            for o in interesting[:10]:
                lines.append(f"  [{o['timestamp']}] {o['observation']}")

        return "\n".join(lines)

    def _save_session(self):
        """Save all data to disk."""
        self._save_observations()
        self._save_path()

        if self._map_dir:
            try:
                summary = self._generate_summary()
                with open(self._map_dir / "summary.txt", "w", encoding="utf-8") as f:
                    f.write(summary)
            except Exception as e:
                print(f"  [Auto] Save summary error: {e}")

    def _print_summary(self):
        """Print exploration summary to terminal."""
        elapsed = time.time() - (self._start_time or time.time())
        mins = int(elapsed // 60)
        secs = int(elapsed % 60)

        print(f"\n  [Auto] Exploration complete")
        print(f"  [Auto] Duration: {mins}m {secs}s | Steps: {self._step}")
        print(f"  [Auto] Observations: {len(self._observations)}")

        if self._area_counts:
            print(f"  [Auto] Areas: {dict(sorted(self._area_counts.items()))}")

        if self._all_objects:
            print(f"  [Auto] Objects: {', '.join(sorted(self._all_objects))}")

        if self._map_dir:
            print(f"  [Auto] Saved to: {self._map_dir}\n")
