# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2026 Detent Contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""CheckpointEngine: in-memory SAVEPOINT registry with atomic rollback."""

from __future__ import annotations

import asyncio
import os
from pathlib import Path

import structlog

from detent.checkpoint.savepoint import ShadowGit
from detent.checkpoint.schemas import FileSnapshot
from detent.observability.metrics import record_savepoint_size
from detent.observability.tracer import get_tracer

logger: structlog.stdlib.BoundLogger = structlog.get_logger()


class CheckpointEngine:
    """Manages file-level SAVEPOINTs with atomic rollback.

    In-memory snapshots provide sub-millisecond savepoint creation.
    Pass shadow_git_path to enable durable backup across process restarts.
    """

    def __init__(self, shadow_git_path: Path | None = None) -> None:
        self._snapshots: dict[str, list[FileSnapshot]] = {}
        self._lock = asyncio.Lock()
        self._shadow: ShadowGit | None = ShadowGit(repo_path=shadow_git_path) if shadow_git_path is not None else None

    async def savepoint(self, ref: str, files: list[str]) -> None:
        """Capture a snapshot of each file before a write operation.

        Args:
            ref: Unique name for this savepoint, e.g. "chk_before_write_004".
            files: Absolute paths to snapshot. Non-existent paths are recorded
                   with existed=False so rollback knows to delete them.
        """
        snapshots: list[FileSnapshot] = []
        for path_str in files:
            path = Path(path_str)
            if path.exists():
                content = path.read_bytes()
                permissions = path.stat().st_mode & 0o777
                snapshots.append(
                    FileSnapshot(
                        path=path_str,
                        content=content,
                        existed=True,
                        permissions=permissions,
                    )
                )
            else:
                snapshots.append(FileSnapshot(path=path_str, content=None, existed=False, permissions=None))

        async with self._lock:
            self._snapshots[ref] = snapshots

        tracer = get_tracer(__name__)
        with tracer.start_as_current_span(
            "detent.checkpoint.savepoint",
            attributes={
                "checkpoint.ref": ref,
                "checkpoint.file_count": len(snapshots),
            },
        ):
            record_savepoint_size(len(snapshots))

        if self._shadow is not None:
            await self._shadow.commit(ref, snapshots)

        logger.info("[checkpoint] savepoint '%s' captured %d file(s)", ref, len(snapshots))

    async def list_savepoints(self) -> list[str]:
        """Return the refs of all active savepoints."""
        async with self._lock:
            return list(self._snapshots.keys())

    async def rollback(self, ref: str) -> None:
        """Restore all files to their state at savepoint time.

        - Files that existed at savepoint: content atomically restored via os.replace().
        - Files that did not exist at savepoint: deleted if present now.

        Args:
            ref: Savepoint ref to roll back to.

        Raises:
            KeyError: If ref has no recorded savepoint.
        """
        async with self._lock:
            snapshots = self._snapshots.get(ref)

        if snapshots is None:
            raise KeyError(f"No savepoint found for ref '{ref}'")

        tracer = get_tracer(__name__)
        with tracer.start_as_current_span(
            "detent.checkpoint.rollback",
            attributes={
                "checkpoint.ref": ref,
                "checkpoint.restored_file_count": len(snapshots),
            },
        ):
            for snap in snapshots:
                path = Path(snap.path)
                if snap.existed:
                    # Atomically restore: write to sibling tmp, then os.replace
                    tmp = path.with_suffix(path.suffix + ".detent-tmp")
                    tmp.parent.mkdir(parents=True, exist_ok=True)
                    tmp.write_bytes(snap.content)  # type: ignore[arg-type]
                    os.replace(tmp, path)
                    if snap.permissions is not None:
                        os.chmod(path, snap.permissions)
                else:
                    # File was created after savepoint — delete it
                    if path.exists():
                        path.unlink()

            logger.info(
                "[checkpoint] rolled back to '%s' (%d file(s) restored)",
                ref,
                len(snapshots),
            )

    async def discard(self, ref: str) -> None:
        """Remove a savepoint from the registry.

        No-op if ref does not exist.

        Args:
            ref: Savepoint ref to discard.
        """
        async with self._lock:
            self._snapshots.pop(ref, None)

        if self._shadow is not None:
            await self._shadow.reset(ref)

        logger.info("[checkpoint] discarded savepoint '%s'", ref)
