"""Audit logging service (SEC7).

Records and queries security-relevant events such as plan applies,
cancellations, resource modifications, corrections, config changes,
and entity deletions.  Supports filtering by plan, project, time
range, and event type, plus a configurable retention/prune policy.

Async write-behind (issue #718)
--------------------------------
By default (``settings.audit_async=True``) ``record()`` enqueues the
audit payload onto an in-process :class:`queue.Queue` and returns
immediately, so the calling domain operation is never blocked by a
SQLite INSERT + COMMIT.  A single daemon background thread drains the
queue and performs the actual database writes.

Ordering guarantees
~~~~~~~~~~~~~~~~~~~
* **Best-effort ordering** — entries are written in the order they are
  enqueued, but because the background thread may lag behind the
  producer, the ``created_at`` timestamps (set at enqueue time) may
  arrive in the database slightly out of wall-clock order relative to
  other concurrent writers.  For the single-writer model used by this
  service this is not a concern in practice.
* **No data loss under normal operation** — :meth:`close` / the context
  manager call :meth:`flush` which joins the background thread after
  sending a sentinel, ensuring all enqueued entries are persisted before
  the session is closed.
* **Back-pressure** — when the queue reaches ``settings.audit_queue_maxsize``
  (default 10 000) ``record()`` blocks until space is available, preventing
  unbounded memory growth under extreme load.

Synchronous fallback
~~~~~~~~~~~~~~~~~~~~
Set ``settings.audit_async=False`` (or ``CLEVERAGENTS_AUDIT_ASYNC=false``)
to restore the original synchronous behaviour.  This is useful for
debugging, strict ordering requirements, or test scenarios that need
immediate persistence.
"""

from __future__ import annotations

import json
import queue
import threading
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Self

import structlog
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.models import AuditLogModel, Base

__all__ = [
    "VALID_EVENT_TYPES",
    "AuditLogEntry",
    "AuditService",
]

_logger = structlog.get_logger(__name__)

# Spec-defined event types (specification.md ~L39764).
VALID_EVENT_TYPES: frozenset[str] = frozenset(
    {
        "plan_applied",
        "plan_cancelled",
        "resource_modified",
        "correction_applied",
        "config_changed",
        "entity_deleted",
        "session_created",
        "auth_success",
        "auth_failure",
    }
)

# Timestamp format: ``strftime('%Y-%m-%dT%H:%M:%S.%f')``
# which produces ``YYYY-MM-DDTHH:MM:SS.ffffff`` (ISO-8601 with microseconds).
# All timestamps must use this format so that lexicographic ``>=`` / ``<``
# comparisons on the ``created_at`` TEXT column remain correct.
_TIMESTAMP_FMT = "%Y-%m-%dT%H:%M:%S.%f"

# Sentinel object used to signal the background writer thread to stop.
_STOP_SENTINEL: object = object()


# ── Domain data class ────────────────────────────────────────────


@dataclass(slots=True)
class AuditLogEntry:
    """A single audit log entry returned by queries."""

    id: int
    event_type: str
    plan_id: str | None
    project_name: str | None
    actor_name: str | None
    user_identity: str | None
    details: dict[str, Any]
    created_at: str

    def as_dict(self) -> dict[str, Any]:
        """Serialize for CLI output."""
        return {
            "id": self.id,
            "event_type": self.event_type,
            "plan_id": self.plan_id,
            "project_name": self.project_name,
            "actor_name": self.actor_name,
            "user_identity": self.user_identity,
            "details": self.details,
            "created_at": self.created_at,
        }


# ── Internal payload type ────────────────────────────────────────


@dataclass(slots=True)
class _AuditPayload:
    """Serialised audit entry waiting to be written to the database."""

    event_type: str
    plan_id: str | None
    project_name: str | None
    actor_name: str | None
    user_identity: str | None
    details_json: str
    created_at: str


# ── Service ──────────────────────────────────────────────────────


class AuditService:
    """Records and queries audit log entries.

    Implements the context-manager protocol so callers can use::

        with AuditService(settings=get_settings()) as svc:
            svc.record(event_type="plan_applied", ...)

    Args:
        settings: Application settings (for DB URL, retention, and async
            mode).
        session: Optional pre-built SQLAlchemy session (for testing).
        database_url: Override the database URL from settings.

    Async mode (``settings.audit_async=True``, the default)
    --------------------------------------------------------
    ``record()`` enqueues the payload and returns a *placeholder*
    :class:`AuditLogEntry` whose ``id`` is ``-1`` (the real ``id`` is
    assigned by SQLite after the background write).  Callers that need
    the real ``id`` immediately should either:

    * Use synchronous mode (``settings.audit_async=False``), or
    * Call :meth:`flush` after ``record()`` to wait for the background
      thread to drain the queue.

    The placeholder ``id=-1`` is intentional and documented — it signals
    "pending persistence".  All other fields are populated correctly.
    """

    def __init__(
        self,
        settings: Settings,
        session: Session | None = None,
        database_url: str | None = None,
    ) -> None:
        if not isinstance(settings, Settings):
            raise TypeError("settings must be a Settings instance")
        self._settings = settings
        self._database_url = database_url
        self._owns_session = session is None
        # When a session is injected (e.g. in tests), use it directly.
        # Otherwise defer engine + table creation to the first call that
        # actually needs the database (_ensure_session).  This avoids
        # running ``Base.metadata.create_all()`` at container-init time
        # which (a) is expensive (~50-150 ms for 38 tables on each fresh
        # SQLite file) and (b) pre-creates the per-scenario DB file,
        # bypassing the template-DB fast-path in the test harness.
        self._session: Session | None = session

        # ── Async write-behind setup ─────────────────────────────
        # Async mode is only active when the service owns its session
        # (i.e. no session was injected).  When a session is injected
        # (typically in tests) we fall back to synchronous mode because
        # SQLite connections are thread-local and cannot be shared across
        # the background writer thread and the calling thread.
        self._async_mode: bool = settings.audit_async and session is None
        self._queue: queue.Queue[_AuditPayload | object] | None = None
        self._writer_thread: threading.Thread | None = None
        self._closed: bool = False

        if self._async_mode:
            self._queue = queue.Queue(maxsize=settings.audit_queue_maxsize)
            self._writer_thread = threading.Thread(
                target=self._writer_loop,
                name="audit-writer",
                daemon=True,
            )
            self._writer_thread.start()

    # ── Lazy session bootstrap ───────────────────────────────────

    def _ensure_session(self) -> Session:
        """Return the active SQLAlchemy session, creating it on first call.

        When no session was injected via the constructor the engine and
        tables are created here rather than in ``__init__``.  This keeps
        the constructor cheap (important when the DI container eagerly
        instantiates the service) and avoids pre-creating the database
        file before the test-harness template-copy mechanism has run.

        Uses ``database_url`` (if provided at construction) to ensure
        audit entries are written to the same database as all other
        services.  Falls back to ``settings.database_url``.

        Only the ``audit_log`` table is created — running the full
        ``Base.metadata.create_all()`` would attempt to create every
        model table (including heavy indexes on ``resources``, etc.)
        which can cause ``disk I/O error`` under concurrent access and
        is unnecessary for the audit service.
        """
        if self._session is None:
            url = self._database_url or self._settings.database_url
            engine = create_engine(url, echo=False)
            Base.metadata.create_all(engine, tables=[AuditLogModel.__table__])
            factory = sessionmaker(bind=engine)
            self._session = factory()
        return self._session

    # ── Background writer loop ───────────────────────────────────

    def _writer_loop(self) -> None:
        """Drain the write-behind queue and persist entries to SQLite.

        Runs on the ``audit-writer`` daemon thread.  Exits when it
        receives :data:`_STOP_SENTINEL` from :meth:`flush`.

        Errors during individual writes are logged and swallowed so that
        a transient DB error does not kill the background thread.
        """
        assert self._queue is not None  # always set when async_mode is True
        while True:
            item = self._queue.get()
            if item is _STOP_SENTINEL:
                self._queue.task_done()
                break
            if not isinstance(item, _AuditPayload):
                self._queue.task_done()
                continue
            try:
                self._write_payload(item)
            except Exception as exc:
                _logger.warning(
                    "audit_async_write_failed",
                    event_type=item.event_type,
                    error_type=type(exc).__name__,
                    error_message=str(exc),
                )
            finally:
                self._queue.task_done()

    def _write_payload(self, payload: _AuditPayload) -> None:
        """Persist a single :class:`_AuditPayload` to the database."""
        row = AuditLogModel(
            event_type=payload.event_type,
            plan_id=payload.plan_id,
            project_name=payload.project_name,
            actor_name=payload.actor_name,
            user_identity=payload.user_identity,
            details=payload.details_json,
            created_at=payload.created_at,
        )
        session = self._ensure_session()
        session.add(row)
        session.commit()

    # ── Context manager ──────────────────────────────────────────

    def __enter__(self) -> Self:
        return self

    def __exit__(self, *_exc: object) -> None:
        self.close()

    def flush(self) -> None:
        """Block until all enqueued audit entries have been persisted.

        In synchronous mode this is a no-op.  In async mode it sends the
        stop sentinel to the background thread and joins it, ensuring
        every entry that was enqueued before this call has been written
        to the database.

        After ``flush()`` the service is **closed** — no further
        ``record()`` calls should be made.  This matches the semantics
        of :meth:`close`.
        """
        if (
            self._async_mode
            and self._queue is not None
            and self._writer_thread is not None
            and self._writer_thread.is_alive()
        ):
            self._queue.put(_STOP_SENTINEL)
            self._writer_thread.join()

    def close(self) -> None:
        """Flush the write-behind queue and close the SQLAlchemy session.

        In async mode, waits for the background thread to drain the queue
        before closing the session, ensuring no data loss.

        Only closes sessions that were created by this service (i.e. not
        externally-provided test sessions).  Safe to call multiple times.
        """
        if self._closed:
            return
        self._closed = True
        # Drain the queue before closing the session.
        self.flush()
        if self._owns_session and self._session is not None:
            self._session.close()

    # ── Record ───────────────────────────────────────────────────

    def record(
        self,
        *,
        event_type: str,
        plan_id: str | None = None,
        project_name: str | None = None,
        actor_name: str | None = None,
        user_identity: str | None = None,
        details: dict[str, Any] | None = None,
    ) -> AuditLogEntry:
        """Write an audit event to the database.

        In **async mode** (default) the entry is enqueued immediately and
        a placeholder :class:`AuditLogEntry` with ``id=-1`` is returned.
        The actual database write happens on the background thread.

        In **synchronous mode** (``settings.audit_async=False``) the
        behaviour is identical to the original implementation: the entry
        is written and committed before this method returns, and the
        returned entry has the real database-assigned ``id``.

        Raises:
            ValueError: If *event_type* is not one of the spec-defined
                event types in :data:`VALID_EVENT_TYPES`.

        Returns:
            The created :class:`AuditLogEntry`.  In async mode the ``id``
            field is ``-1`` (pending persistence).
        """
        if event_type not in VALID_EVENT_TYPES:
            raise ValueError(
                f"Unknown event_type {event_type!r}; "
                f"expected one of {sorted(VALID_EVENT_TYPES)}"
            )
        now = datetime.now(tz=UTC).strftime(_TIMESTAMP_FMT)
        details_json = json.dumps(details or {}, default=str)

        if self._async_mode and self._queue is not None:
            payload = _AuditPayload(
                event_type=event_type,
                plan_id=plan_id,
                project_name=project_name,
                actor_name=actor_name,
                user_identity=user_identity,
                details_json=details_json,
                created_at=now,
            )
            self._queue.put(payload)
            # Return a placeholder entry — id is not yet known.
            return AuditLogEntry(
                id=-1,
                event_type=event_type,
                plan_id=plan_id,
                project_name=project_name,
                actor_name=actor_name,
                user_identity=user_identity,
                details=details or {},
                created_at=now,
            )

        # Synchronous path (original behaviour).
        row = AuditLogModel(
            event_type=event_type,
            plan_id=plan_id,
            project_name=project_name,
            actor_name=actor_name,
            user_identity=user_identity,
            details=details_json,
            created_at=now,
        )
        session = self._ensure_session()
        session.add(row)
        session.commit()
        session.refresh(row)

        return self._row_to_entry(row)

    # ── Query ────────────────────────────────────────────────────

    def list_entries(
        self,
        *,
        plan_id: str | None = None,
        project_name: str | None = None,
        event_type: str | None = None,
        since: str | None = None,
        limit: int = 100,
    ) -> list[AuditLogEntry]:
        """Query audit log entries with optional filters.

        Args:
            plan_id: Filter by exact plan_id.
            project_name: Filter by exact project name.
            event_type: Filter by event type.
            since: ISO-8601 timestamp (``YYYY-MM-DDTHH:MM:SS.ffffff``
                format recommended to match the stored format); only
                entries created at or after this time are returned.
                The comparison is lexicographic on the TEXT column.
            limit: Maximum number of entries to return.

        Returns:
            List of matching entries, most recent first.
        """
        session = self._ensure_session()
        query = session.query(AuditLogModel)

        if plan_id is not None:
            query = query.filter(AuditLogModel.plan_id == plan_id)
        if project_name is not None:
            query = query.filter(AuditLogModel.project_name == project_name)
        if event_type is not None:
            query = query.filter(AuditLogModel.event_type == event_type)
        if since is not None:
            query = query.filter(AuditLogModel.created_at >= since)

        query = query.order_by(
            AuditLogModel.created_at.desc(),
            AuditLogModel.id.desc(),
        ).limit(limit)

        return [self._row_to_entry(row) for row in query.all()]

    def get_entry(self, audit_id: int) -> AuditLogEntry | None:
        """Fetch a single audit entry by ID.

        Returns:
            The entry, or ``None`` if not found.
        """
        session = self._ensure_session()
        row = session.query(AuditLogModel).filter(AuditLogModel.id == audit_id).first()
        if row is None:
            return None
        return self._row_to_entry(row)

    def count(self) -> int:
        """Return the total number of audit log entries."""
        return self._ensure_session().query(AuditLogModel).count()

    # ── Retention / prune ────────────────────────────────────────

    def prune(self, retention_days: int | None = None) -> int:
        """Delete audit entries older than *retention_days*.

        Args:
            retention_days: Override the configured retention.  If
                ``None``, uses ``settings.audit_retention_days``.
                A value of ``0`` means "keep everything" (no pruning).

        Returns:
            Number of rows deleted.
        """
        days = (
            retention_days
            if retention_days is not None
            else self._settings.audit_retention_days
        )
        if days <= 0:
            return 0
        cutoff = (datetime.now(tz=UTC) - timedelta(days=days)).strftime(_TIMESTAMP_FMT)
        session = self._ensure_session()
        result = (
            session.query(AuditLogModel)
            .filter(AuditLogModel.created_at < cutoff)
            .delete(synchronize_session="fetch")
        )
        session.commit()
        return result

    # ── Helpers ───────────────────────────────────────────────────

    @staticmethod
    def _row_to_entry(row: AuditLogModel) -> AuditLogEntry:
        """Convert a SQLAlchemy model row to a domain data class."""
        try:
            details = json.loads(row.details) if row.details else {}  # type: ignore[arg-type]
        except (json.JSONDecodeError, TypeError):
            details = {}
        return AuditLogEntry(
            id=row.id,  # type: ignore[arg-type]
            event_type=row.event_type,  # type: ignore[arg-type]
            plan_id=row.plan_id,  # type: ignore[arg-type]
            project_name=row.project_name,  # type: ignore[arg-type]
            actor_name=row.actor_name,  # type: ignore[arg-type]
            user_identity=row.user_identity,  # type: ignore[arg-type]
            details=details,
            created_at=row.created_at,  # type: ignore[arg-type]
        )
