"""Concurrency lock service for plan and project resources.

The ``LockService`` provides advisory locking with configurable TTL
for plan-level and project-level mutual exclusion.  Locks are
persisted in the ``locks`` table and enforced before state transitions.

## Features

- **Re-entrant**: same owner can re-acquire a held lock (extends TTL).
- **Conflict detection**: different owner receives ``LockConflictError``.
- **Automatic expiry**: expired locks are transparently cleaned up.
- **Renewal**: owners can extend TTL for long-running phases.
- **Graceful shutdown**: ``release_all_for_owner`` cleans up on exit.
- **Startup cleanup**: ``cleanup_expired`` purges stale rows.
- **Diagnostics**: ``count_stale_locks`` for health checks.

## Lock TTL Defaults

| Constant                  | Value | Description                   |
|---------------------------|-------|-------------------------------|
| ``DEFAULT_LOCK_TTL_SECS`` | 300   | Default lock lifetime (5 min) |
| ``MAX_LOCK_TTL_SECS``     | 3600  | Maximum allowed TTL (1 hr)    |
| ``MIN_LOCK_TTL_SECS``     | 5     | Minimum allowed TTL (5 sec)   |
"""

from __future__ import annotations

from collections.abc import Callable
from datetime import UTC, datetime, timedelta

import structlog
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session

from cleveragents.core.exceptions import (
    LockConflictError,
    LockExpiredError,
    ValidationError,
)
from cleveragents.infrastructure.database.models import LockModel

logger = structlog.get_logger(__name__)

DEFAULT_LOCK_TTL_SECS: int = 300
MAX_LOCK_TTL_SECS: int = 3600
MIN_LOCK_TTL_SECS: int = 5

VALID_RESOURCE_TYPES: frozenset[str] = frozenset({"plan", "project"})


class LockService:
    """Advisory lock manager for plan and project resources.

    All database access goes through the injected *session_factory*.

    Args:
        session_factory: Callable returning a ``Session`` instance.
    """

    def __init__(self, session_factory: Callable[[], Session]) -> None:
        if session_factory is None:
            raise ValidationError("session_factory is required")
        self._session_factory = session_factory
        self._logger = logger.bind(service="lock_service")

    # ------------------------------------------------------------------
    # Validation helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _validate_resource_type(resource_type: str) -> None:
        """Validate that *resource_type* is a known value.

        Args:
            resource_type: Must be ``'plan'`` or ``'project'``.

        Raises:
            ValidationError: If the resource type is not recognised.
        """
        if not resource_type:
            raise ValidationError("resource_type must not be empty")
        if resource_type not in VALID_RESOURCE_TYPES:
            raise ValidationError(
                f"resource_type must be one of {sorted(VALID_RESOURCE_TYPES)}, "
                f"got '{resource_type}'"
            )

    @staticmethod
    def _validate_non_empty(value: str, name: str) -> None:
        """Validate that *value* is a non-empty string.

        Args:
            value: The string to check.
            name: Human-readable parameter name for the error message.

        Raises:
            ValidationError: If *value* is empty.
        """
        if not value:
            raise ValidationError(f"{name} must not be empty")

    @staticmethod
    def _validate_ttl(ttl_seconds: int) -> None:
        """Validate that *ttl_seconds* falls within bounds.

        Args:
            ttl_seconds: TTL to validate.

        Raises:
            ValidationError: If out of range.
        """
        if ttl_seconds < MIN_LOCK_TTL_SECS:
            raise ValidationError(
                f"ttl_seconds must be >= {MIN_LOCK_TTL_SECS}, got {ttl_seconds}"
            )
        if ttl_seconds > MAX_LOCK_TTL_SECS:
            raise ValidationError(
                f"ttl_seconds must be <= {MAX_LOCK_TTL_SECS}, got {ttl_seconds}"
            )

    # ------------------------------------------------------------------
    # Core operations
    # ------------------------------------------------------------------

    def acquire(
        self,
        owner_id: str,
        resource_type: str,
        resource_id: str,
        ttl_seconds: int = DEFAULT_LOCK_TTL_SECS,
    ) -> bool:
        """Acquire an advisory lock on a resource.

        If the same *owner_id* already holds the lock, the TTL is
        extended (re-entrant acquisition).  If a **different** owner
        holds a non-expired lock, a ``LockConflictError`` is raised.

        Expired locks held by other owners are transparently replaced.

        Args:
            owner_id: Unique identifier for the lock owner.
            resource_type: ``'plan'`` or ``'project'``.
            resource_id: Identifier of the resource to lock.
            ttl_seconds: Time-to-live in seconds (default 300).

        Returns:
            ``True`` if the lock was acquired or renewed.

        Raises:
            ValidationError: If any parameter is invalid.
            LockConflictError: If a different owner holds the lock.
        """
        self._validate_non_empty(owner_id, "owner_id")
        self._validate_resource_type(resource_type)
        self._validate_non_empty(resource_id, "resource_id")
        self._validate_ttl(ttl_seconds)

        now = datetime.now(tz=UTC)
        expires = now + timedelta(seconds=ttl_seconds)
        now_iso = now.isoformat()
        expires_iso = expires.isoformat()

        session = self._session_factory()
        try:
            stmt = select(LockModel).where(
                LockModel.resource_type == resource_type,
                LockModel.resource_id == resource_id,
            )
            existing: LockModel | None = session.execute(stmt).scalar_one_or_none()

            if existing is not None:
                existing_owner = str(existing.owner_id)
                existing_expires = str(existing.expires_at)

                if existing_owner == owner_id:
                    # Re-entrant: extend TTL
                    existing.expires_at = expires_iso
                    session.commit()
                    self._logger.info(
                        "Lock renewed (re-entrant)",
                        owner_id=owner_id,
                        resource_type=resource_type,
                        resource_id=resource_id,
                    )
                    return True

                # Different owner — check expiry
                if existing_expires >= now_iso:
                    raise LockConflictError(
                        resource_type=resource_type,
                        resource_id=resource_id,
                        owner_id=existing_owner,
                    )

                # Expired: replace
                existing.owner_id = owner_id
                existing.acquired_at = now_iso
                existing.expires_at = expires_iso
                session.commit()
                self._logger.info(
                    "Expired lock replaced",
                    owner_id=owner_id,
                    resource_type=resource_type,
                    resource_id=resource_id,
                )
                return True

            # No existing lock — create new
            lock = LockModel(
                owner_id=owner_id,
                resource_type=resource_type,
                resource_id=resource_id,
                acquired_at=now_iso,
                expires_at=expires_iso,
            )
            session.add(lock)
            session.commit()
            self._logger.info(
                "Lock acquired",
                owner_id=owner_id,
                resource_type=resource_type,
                resource_id=resource_id,
            )
            return True
        except LockConflictError:
            session.rollback()
            raise
        except Exception:
            session.rollback()
            raise

    def release(
        self,
        owner_id: str,
        resource_type: str,
        resource_id: str,
    ) -> bool:
        """Release a lock held by *owner_id*.

        Only the owning identity may release.  Returns ``False`` when
        no matching lock exists.

        Args:
            owner_id: The identity that holds the lock.
            resource_type: ``'plan'`` or ``'project'``.
            resource_id: Resource identifier.

        Returns:
            ``True`` if a lock was released, ``False`` otherwise.

        Raises:
            ValidationError: If parameters are invalid.
        """
        self._validate_non_empty(owner_id, "owner_id")
        self._validate_resource_type(resource_type)
        self._validate_non_empty(resource_id, "resource_id")

        session = self._session_factory()
        try:
            stmt = delete(LockModel).where(
                LockModel.owner_id == owner_id,
                LockModel.resource_type == resource_type,
                LockModel.resource_id == resource_id,
            )
            result = session.execute(stmt)
            session.commit()
            deleted: bool = int(getattr(result, "rowcount", 0)) > 0
            if deleted:
                self._logger.info(
                    "Lock released",
                    owner_id=owner_id,
                    resource_type=resource_type,
                    resource_id=resource_id,
                )
            return deleted
        except Exception:
            session.rollback()
            raise

    def renew(
        self,
        owner_id: str,
        resource_type: str,
        resource_id: str,
        ttl_seconds: int = DEFAULT_LOCK_TTL_SECS,
    ) -> bool:
        """Renew (extend) a lock's TTL.

        The lock must be held by *owner_id* and must not have expired.

        Args:
            owner_id: The owning identity.
            resource_type: ``'plan'`` or ``'project'``.
            resource_id: Resource identifier.
            ttl_seconds: New TTL from now.

        Returns:
            ``True`` if the lock was renewed.

        Raises:
            ValidationError: If parameters are invalid.
            LockExpiredError: If the lock has already expired.
        """
        self._validate_non_empty(owner_id, "owner_id")
        self._validate_resource_type(resource_type)
        self._validate_non_empty(resource_id, "resource_id")
        self._validate_ttl(ttl_seconds)

        now = datetime.now(tz=UTC)
        now_iso = now.isoformat()
        expires_iso = (now + timedelta(seconds=ttl_seconds)).isoformat()

        session = self._session_factory()
        try:
            stmt = select(LockModel).where(
                LockModel.owner_id == owner_id,
                LockModel.resource_type == resource_type,
                LockModel.resource_id == resource_id,
            )
            lock: LockModel | None = session.execute(stmt).scalar_one_or_none()
            if lock is None:
                return False

            if str(lock.expires_at) < now_iso:
                raise LockExpiredError(
                    resource_type=resource_type,
                    resource_id=resource_id,
                )

            lock.expires_at = expires_iso
            session.commit()
            self._logger.info(
                "Lock renewed",
                owner_id=owner_id,
                resource_type=resource_type,
                resource_id=resource_id,
            )
            return True
        except LockExpiredError:
            session.rollback()
            raise
        except Exception:
            session.rollback()
            raise

    def release_all_for_owner(self, owner_id: str) -> int:
        """Release every lock held by *owner_id* (graceful shutdown).

        Args:
            owner_id: Identity whose locks should be released.

        Returns:
            Number of locks released.

        Raises:
            ValidationError: If *owner_id* is empty.
        """
        self._validate_non_empty(owner_id, "owner_id")

        session = self._session_factory()
        try:
            stmt = delete(LockModel).where(LockModel.owner_id == owner_id)
            result = session.execute(stmt)
            session.commit()
            count: int = int(getattr(result, "rowcount", 0))
            if count > 0:
                self._logger.info(
                    "Released all locks for owner",
                    owner_id=owner_id,
                    count=count,
                )
            return count
        except Exception:
            session.rollback()
            raise

    def cleanup_expired(self) -> int:
        """Delete all expired locks (startup routine).

        Returns:
            Number of expired locks purged.
        """
        now_iso = datetime.now(tz=UTC).isoformat()

        session = self._session_factory()
        try:
            stmt = delete(LockModel).where(LockModel.expires_at < now_iso)
            result = session.execute(stmt)
            session.commit()
            count: int = int(getattr(result, "rowcount", 0))
            if count > 0:
                self._logger.info("Expired locks cleaned up", count=count)
            return count
        except Exception:
            session.rollback()
            raise

    def count_stale_locks(self) -> int:
        """Count locks that have expired but not been cleaned up.

        Intended for the ``agents diagnostics`` health check.

        Returns:
            Number of stale (expired) locks.
        """
        now_iso = datetime.now(tz=UTC).isoformat()

        session = self._session_factory()
        stmt = (
            select(func.count())
            .select_from(LockModel)
            .where(
                LockModel.expires_at < now_iso,
            )
        )
        result: int = session.execute(stmt).scalar_one()
        return result

    def is_locked(self, resource_type: str, resource_id: str) -> bool:
        """Check whether a resource has an active (non-expired) lock.

        Args:
            resource_type: ``'plan'`` or ``'project'``.
            resource_id: Resource identifier.

        Returns:
            ``True`` if an active lock exists.

        Raises:
            ValidationError: If parameters are invalid.
        """
        self._validate_resource_type(resource_type)
        self._validate_non_empty(resource_id, "resource_id")

        now_iso = datetime.now(tz=UTC).isoformat()

        session = self._session_factory()
        stmt = (
            select(func.count())
            .select_from(LockModel)
            .where(
                LockModel.resource_type == resource_type,
                LockModel.resource_id == resource_id,
                LockModel.expires_at >= now_iso,
            )
        )
        result: int = session.execute(stmt).scalar_one()
        return result > 0
