"""Session manager — lifecycle management for NestDX sessions."""

from __future__ import annotations

import hashlib
import logging
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path

from nestdx.config.schema import NestConfig, parse_duration
from nestdx.policy.secrets import SecretsScanner
from nestdx.policy.watcher import FileWatcher
from nestdx.runtime.container import ContainerRuntime
from nestdx.session import git
from nestdx.session.models import Session, SessionStatus, ToolRun
from nestdx.session.storage import SessionStore, load_session

logger = logging.getLogger(__name__)

# A session active for longer than this is considered stale
_STALE_SESSION_HOURS = 24


class SessionError(Exception):
    """Base session error."""


class ActiveSessionExistsError(SessionError):
    """Raised when trying to start a session while one is already active."""


class NoActiveSessionError(SessionError):
    """Raised when an operation requires an active session but none exists."""


class SessionNotFoundError(SessionError):
    """Raised when a session ID is not found."""


class StaleSessionError(SessionError):
    """Raised when a session lock file is older than the stale threshold."""


class SessionManager:
    """Manages session lifecycle: create, track, finalize."""

    def __init__(self, config: NestConfig, project_root: Path, *, force_local: bool = False):
        self.config = config
        self.project_root = project_root
        self.sessions_dir = project_root / config.tracking.session_dir
        self._active_session_file = project_root / ".nestdx" / "active_session"
        self._watcher: FileWatcher | None = None
        self._container: ContainerRuntime | None = None
        self._force_local = force_local
        self._store = SessionStore(self.sessions_dir)

    def _generate_id(self) -> str:
        return uuid.uuid4().hex[:12]

    def _config_hash(self) -> str | None:
        config_path = self.project_root / "nestdx.yaml"
        if config_path.is_file():
            content = config_path.read_bytes()
            return f"sha256:{hashlib.sha256(content).hexdigest()[:16]}"
        return None

    def start(self, *, force: bool = False) -> Session:
        """Create and persist a new active session.

        Args:
            force: If True, force-stop any existing (possibly stale) session.

        Raises:
            ActiveSessionExistsError: If a session is already active.
            StaleSessionError: If a stale session exists and force=False.
        """
        existing = self.get_active()
        if existing is not None:
            stale = self.check_stale()
            if stale is not None and not force:
                elapsed = datetime.now(timezone.utc) - stale.start_time
                hours = int(elapsed.total_seconds() // 3600)
                raise StaleSessionError(
                    f"Session {stale.id} has been active for {hours}h. "
                    f"It may be stale. Run `nestdx stop --force` or `nestdx start --force` to override."
                )
            if force:
                self.force_stop()
            else:
                raise ActiveSessionExistsError(
                    f"Session {existing.id} is already active. Run `nestdx stop` first."
                )

        # Ensure directories exist
        self.sessions_dir.mkdir(parents=True, exist_ok=True)
        self._active_session_file.parent.mkdir(parents=True, exist_ok=True)

        # Snapshot git state
        git_snap = git.snapshot(self.project_root)

        session = Session(
            id=self._generate_id(),
            status=SessionStatus.ACTIVE,
            start_time=datetime.now(timezone.utc),
            git_snapshot=git_snap,
            config_hash=self._config_hash(),
        )

        self._store.save(session)
        self._active_session_file.write_text(session.id)

        return session

    @property
    def container_mode(self) -> bool:
        """True if the session should use a container runtime."""
        return self.config.environment is not None and not self._force_local

    def start_container(self, session_id: str) -> str:
        """Build image and start container for the session.

        Returns the container ID.

        Raises:
            NoContainerRuntimeError: If no runtime is installed.
            ContainerBuildError: If image build fails.
            ContainerRunError: If container fails to start.
        """
        if self.config.environment is None:
            raise SessionError("No environment config — cannot start container.")

        runtime = ContainerRuntime(
            self.config.environment,
            self.project_root,
            resources=self.config.policies.resources,
            network_policy=self.config.policies.network,
        )
        runtime.build()
        container_id = runtime.start(session_id)
        self._container = runtime
        return container_id

    def stop_container(self) -> None:
        """Stop and remove the workspace container."""
        if self._container is None:
            return
        self._container.stop()
        self._container = None

    def start_watcher(self) -> bool:
        """Start the file watcher for the current session.

        Returns True if the watcher started, False if it failed (graceful
        degradation — session continues without real-time watching).
        """
        if self._watcher is not None:
            return self._watcher.is_running
        scanner = SecretsScanner(self.config.policies.secrets, project_root=self.project_root)
        self._watcher = FileWatcher(self.project_root, scanner)
        started = self._watcher.start()
        if not started:
            logger.warning("File watcher unavailable — using git-diff-only change detection.")
            self._watcher = None
        return started

    def stop_watcher(self) -> None:
        """Stop the file watcher and merge results into the active session."""
        if self._watcher is None or not self._watcher.is_running:
            return
        _changes, violations = self._watcher.stop()
        self._watcher = None

        # Merge watcher-detected violations into active session
        if violations:
            session = self.get_active()
            if session is not None:
                session.violations.extend(violations)
                self._store.save(session)

    def get_active(self) -> Session | None:
        """Return the active session, or None if no session is active."""
        if not self._active_session_file.is_file():
            return None

        session_id = self._active_session_file.read_text().strip()
        if not session_id:
            return None

        session_path = self.sessions_dir / f"{session_id}.json"
        if not session_path.is_file():
            # Stale lock file — clean up
            logger.debug("Cleaning up stale lock file (no session JSON): %s", session_id)
            self._active_session_file.unlink(missing_ok=True)
            return None

        session = load_session(session_path)
        if session is None:
            logger.warning("Active session file corrupted: %s", session_path)
            self._active_session_file.unlink(missing_ok=True)
            return None

        return session

    def check_stale(self) -> Session | None:
        """Return the active session if it's stale (older than 24h), else None."""
        session = self.get_active()
        if session is None:
            return None
        elapsed = datetime.now(timezone.utc) - session.start_time
        if elapsed > timedelta(hours=_STALE_SESSION_HOURS):
            logger.debug("Session %s is stale (%s old)", session.id, elapsed)
            return session
        return None

    def check_session_duration(self, session: Session) -> tuple[bool, str]:
        """Check if the active session has exceeded max_session_duration.

        Returns (exceeded: bool, message: str).
        """
        duration_str = self.config.policies.resources.max_session_duration
        if not duration_str:
            return False, ""

        max_seconds = parse_duration(duration_str)
        elapsed = (datetime.now(timezone.utc) - session.start_time).total_seconds()

        if elapsed > max_seconds:
            return True, (
                f"Session has exceeded max duration of {duration_str} "
                f"(elapsed: {int(elapsed // 60)}m {int(elapsed % 60)}s)."
            )
        return False, ""

    def add_tool_run(self, tool_run: ToolRun) -> None:
        """Append a tool run to the active session.

        Raises:
            NoActiveSessionError: If no session is active.
        """
        session = self.get_active()
        if session is None:
            raise NoActiveSessionError("No active session. Run `nestdx start` first.")

        session.tool_runs.append(tool_run)
        self._store.save(session)

    def stop(self) -> Session:
        """Finalize the active session.

        Captures git diff, stops watcher, marks session as completed.
        Policy checks are run separately by the CLI layer.

        Raises:
            NoActiveSessionError: If no session is active.
        """
        # Stop watcher first (merges violations into session)
        self.stop_watcher()
        self.stop_container()

        session = self.get_active()
        if session is None:
            raise NoActiveSessionError("No active session.")

        # Capture file changes since session start
        if session.git_snapshot:
            session.file_changes = git.diff_since(self.project_root, session.git_snapshot)

        session.status = SessionStatus.COMPLETED
        session.end_time = datetime.now(timezone.utc)

        self._store.save(session)
        self._active_session_file.unlink(missing_ok=True)

        return session

    def abort(self) -> Session:
        """Mark active session as aborted (no policy checks).

        Raises:
            NoActiveSessionError: If no session is active.
        """
        self.stop_watcher()
        self.stop_container()

        session = self.get_active()
        if session is None:
            raise NoActiveSessionError("No active session.")

        session.status = SessionStatus.ABORTED
        session.end_time = datetime.now(timezone.utc)

        self._store.save(session)
        self._active_session_file.unlink(missing_ok=True)

        return session

    def force_stop(self) -> Session | None:
        """Force-stop a potentially stale session.

        Removes the active session file and marks the session as aborted.
        Returns the session if found, None if no lock file exists.
        """
        if not self._active_session_file.is_file():
            return None

        session_id = self._active_session_file.read_text().strip()
        self._active_session_file.unlink(missing_ok=True)

        session_path = self.sessions_dir / f"{session_id}.json"
        if session_path.is_file():
            session = load_session(session_path)
            if session:
                session.status = SessionStatus.ABORTED
                session.end_time = datetime.now(timezone.utc)
                self._store.save(session)
                return session

        return None

    def list_sessions(self, limit: int = 20) -> list[Session]:
        """List historical sessions, newest first."""
        return self._store.query(limit=limit)

    @property
    def store(self) -> SessionStore:
        """Expose the session store for direct queries and aggregation."""
        return self._store

    def get_session(self, session_id: str) -> Session:
        """Load a specific session by ID.

        Raises:
            SessionNotFoundError: If session file not found or corrupted.
        """
        session = self._store.load(session_id)
        if session is None:
            raise SessionNotFoundError(f"Session not found: {session_id}")
        return session
