"""ChangeSet persistence repository and SQLite-backed store.

Provides ``ChangeSetEntryRepository`` and ``ToolInvocationRepository``
for CRUD on the ``changeset_entries`` and ``tool_invocations`` tables,
plus ``SqliteChangeSetStore`` which implements the
``ChangeSetStore`` protocol using these repositories.

All repositories follow the session-factory pattern (ADR-007).
"""

from __future__ import annotations

import json
import logging
from collections.abc import Callable
from datetime import datetime
from typing import Any, cast

from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
from ulid import ULID

from cleveragents.core.exceptions import DatabaseError
from cleveragents.core.retry_patterns import (
    retry_database_operation as database_retry,
)
from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    SpecChangeSet,
    ToolInvocation,
)
from cleveragents.infrastructure.database.models import (
    ChangeSetEntryModel,
    ToolInvocationModel,
)

_logger = logging.getLogger(__name__)


def _safe_json_loads(
    raw: str | None,
    *,
    column: str = "",
    default: Any = None,
) -> Any:
    """Deserialise a JSON string from the database.

    Returns *default* (instead of raising) when the stored data is
    corrupt or not valid JSON so that a single bad row does not break
    bulk reads.
    """
    if not raw:
        return default
    try:
        return json.loads(raw)
    except (json.JSONDecodeError, TypeError, ValueError):
        _logger.warning(
            "corrupt_json_column",
            extra={"column": column, "raw_length": len(raw)},
        )
        return default


class ChangeSetEntryRepository:
    """Repository for persisting ChangeEntry records.

    Uses the session-factory pattern: each public method obtains
    a session from the factory.  Callers are responsible for commit.
    """

    def __init__(
        self,
        session_factory: Callable[[], Session],
    ) -> None:
        """Initialise with a callable returning a SQLAlchemy Session."""
        if session_factory is None:
            raise ValueError("session_factory must not be None")
        self._sf = session_factory

    def _session(self) -> Session:
        return self._sf()

    @database_retry
    def save_entry(
        self,
        changeset_id: str,
        entry: ChangeEntry,
    ) -> None:
        """Persist a single ChangeEntry row."""
        if not changeset_id:
            raise ValueError("changeset_id must not be empty")
        if not isinstance(entry, ChangeEntry):
            raise TypeError("entry must be a ChangeEntry instance")

        session = self._session()
        try:
            model = ChangeSetEntryModel(
                entry_id=entry.entry_id,
                changeset_id=changeset_id,
                plan_id=entry.plan_id,
                resource_id=entry.resource_id,
                tool_name=entry.tool_name,
                operation=entry.operation,
                path=entry.path,
                before_hash=entry.before_hash,
                after_hash=entry.after_hash,
                before_mode=entry.before_mode,
                after_mode=entry.after_mode,
                timestamp=entry.timestamp.isoformat(),
            )
            session.add(model)
            session.flush()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to save changeset entry: {exc}") from exc

    @database_retry
    def get_entries_for_changeset(
        self,
        changeset_id: str,
    ) -> list[ChangeEntry]:
        """Return all ChangeEntry records for a changeset."""
        if not changeset_id:
            raise ValueError("changeset_id must not be empty")

        session = self._session()
        try:
            rows = (
                session.query(ChangeSetEntryModel)
                .filter_by(changeset_id=changeset_id)
                .order_by(ChangeSetEntryModel.timestamp)
                .all()
            )
            return [self._to_domain(r) for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get entries: {exc}") from exc

    @database_retry
    def get_entries_for_plan(
        self,
        plan_id: str,
    ) -> list[ChangeEntry]:
        """Return all ChangeEntry records for a plan."""
        if not plan_id:
            raise ValueError("plan_id must not be empty")

        session = self._session()
        try:
            rows = (
                session.query(ChangeSetEntryModel)
                .filter_by(plan_id=plan_id)
                .order_by(ChangeSetEntryModel.timestamp)
                .all()
            )
            return [self._to_domain(r) for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get entries for plan: {exc}") from exc

    @database_retry
    def delete_for_changeset(self, changeset_id: str) -> int:
        """Delete all entries for a changeset."""
        if not changeset_id:
            raise ValueError("changeset_id must not be empty")

        session = self._session()
        try:
            count: int = (
                session.query(ChangeSetEntryModel)
                .filter_by(changeset_id=changeset_id)
                .delete()
            )
            session.flush()
            return count
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete entries: {exc}") from exc

    @database_retry
    def delete_for_plan(self, plan_id: str) -> int:
        """Delete all entries for a plan."""
        if not plan_id:
            raise ValueError("plan_id must not be empty")

        session = self._session()
        try:
            count: int = (
                session.query(ChangeSetEntryModel).filter_by(plan_id=plan_id).delete()
            )
            session.flush()
            return count
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete entries: {exc}") from exc

    @staticmethod
    def _to_domain(row: ChangeSetEntryModel) -> ChangeEntry:
        """Convert a DB row to a ChangeEntry domain object."""
        bh = cast("str | None", row.before_hash)
        ah = cast("str | None", row.after_hash)
        bm_raw = cast("int | None", row.before_mode)
        am_raw = cast("int | None", row.after_mode)
        return ChangeEntry(
            entry_id=cast(str, row.entry_id),
            plan_id=cast(str, row.plan_id),
            resource_id=cast(str, row.resource_id),
            tool_name=cast(str, row.tool_name),
            operation=ChangeOperation(cast(str, row.operation)),
            path=cast(str, row.path),
            before_hash=bh if bh else None,
            after_hash=ah if ah else None,
            before_mode=bm_raw,
            after_mode=am_raw,
            timestamp=datetime.fromisoformat(
                cast(str, row.timestamp),
            ),
        )


class ToolInvocationRepository:
    """Repository for persisting ToolInvocation records."""

    def __init__(
        self,
        session_factory: Callable[[], Session],
    ) -> None:
        if session_factory is None:
            raise ValueError("session_factory must not be None")
        self._sf = session_factory

    def _session(self) -> Session:
        return self._sf()

    @database_retry
    def save_invocation(
        self,
        invocation: ToolInvocation,
        changeset_id: str | None = None,
    ) -> None:
        """Persist a single ToolInvocation row."""
        if not isinstance(invocation, ToolInvocation):
            raise TypeError("invocation must be a ToolInvocation instance")

        session = self._session()
        try:
            result_json: str | None = None
            if invocation.result is not None:
                result_json = json.dumps(
                    invocation.result,
                    allow_nan=False,
                )

            completed_iso: str | None = None
            if invocation.completed_at is not None:
                completed_iso = invocation.completed_at.isoformat()

            pm_json: str | None = None
            if invocation.provider_metadata is not None:
                pm_json = json.dumps(
                    invocation.provider_metadata,
                    allow_nan=False,
                )

            cm_json: str | None = None
            if invocation.container_metadata is not None:
                cm_json = json.dumps(
                    invocation.container_metadata,
                    allow_nan=False,
                )

            model = ToolInvocationModel(
                invocation_id=invocation.invocation_id,
                changeset_id=changeset_id,
                plan_id=invocation.plan_id,
                tool_name=invocation.tool_name,
                skill_name=invocation.skill_name,
                arguments_json=json.dumps(invocation.arguments, allow_nan=False),
                result_json=result_json,
                error=invocation.error,
                success=invocation.success,
                duration_ms=invocation.duration_ms,
                started_at=invocation.started_at.isoformat(),
                completed_at=completed_iso,
                change_ids_json=json.dumps(invocation.change_ids),
                sequence_number=invocation.sequence_number,
                sandbox_path=invocation.sandbox_path,
                resource_refs_json=json.dumps(
                    invocation.resource_refs,
                ),
                provider_metadata_json=pm_json,
                container_metadata_json=cm_json,
            )
            session.add(model)
            session.flush()
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to save tool invocation: {exc}") from exc

    @database_retry
    def get_invocations_for_plan(
        self,
        plan_id: str,
    ) -> list[ToolInvocation]:
        """Return all ToolInvocation records for a plan."""
        if not plan_id:
            raise ValueError("plan_id must not be empty")

        session = self._session()
        try:
            rows = (
                session.query(ToolInvocationModel)
                .filter_by(plan_id=plan_id)
                .order_by(ToolInvocationModel.sequence_number)
                .all()
            )
            return [self._to_domain(r) for r in rows]
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            raise DatabaseError(f"Failed to get invocations: {exc}") from exc

    @database_retry
    def delete_for_plan(self, plan_id: str) -> int:
        """Delete all invocations for a plan."""
        if not plan_id:
            raise ValueError("plan_id must not be empty")

        session = self._session()
        try:
            count: int = (
                session.query(ToolInvocationModel).filter_by(plan_id=plan_id).delete()
            )
            session.flush()
            return count
        except (OperationalError, SQLAlchemyDatabaseError) as exc:
            session.rollback()
            raise DatabaseError(f"Failed to delete invocations: {exc}") from exc

    @staticmethod
    def _to_domain(row: ToolInvocationModel) -> ToolInvocation:
        """Convert a DB row to a ToolInvocation domain object."""
        args_raw = cast("str | None", row.arguments_json)
        args: dict[str, Any] = _safe_json_loads(
            args_raw, column="arguments_json", default={}
        )
        res_raw = cast("str | None", row.result_json)
        result: dict[str, Any] | None = _safe_json_loads(res_raw, column="result_json")
        cids_raw = cast("str | None", row.change_ids_json)
        change_ids: list[str] = _safe_json_loads(
            cids_raw, column="change_ids_json", default=[]
        )
        rr_raw = cast("str | None", row.resource_refs_json)
        resource_refs: list[str] = _safe_json_loads(
            rr_raw, column="resource_refs_json", default=[]
        )
        pm_raw = cast("str | None", row.provider_metadata_json)
        provider_meta: dict[str, Any] | None = _safe_json_loads(
            pm_raw, column="provider_metadata_json"
        )
        cm_raw = cast("str | None", row.container_metadata_json)
        container_meta: dict[str, Any] | None = _safe_json_loads(
            cm_raw, column="container_metadata_json"
        )

        dur_raw = cast("float | None", row.duration_ms)
        seq_raw = cast("int | None", row.sequence_number)

        completed_raw = cast("str | None", row.completed_at)
        completed_dt = datetime.fromisoformat(completed_raw) if completed_raw else None

        return ToolInvocation(
            invocation_id=cast(str, row.invocation_id),
            plan_id=cast(str, row.plan_id),
            tool_name=cast(str, row.tool_name),
            skill_name=cast("str | None", row.skill_name),
            arguments=args,
            result=result,
            error=cast("str | None", row.error),
            success=cast(bool, row.success),
            duration_ms=float(dur_raw) if dur_raw else 0.0,
            started_at=datetime.fromisoformat(
                cast(str, row.started_at),
            ),
            completed_at=completed_dt,
            change_ids=change_ids,
            sequence_number=int(seq_raw) if seq_raw else 0,
            sandbox_path=cast("str | None", row.sandbox_path),
            resource_refs=resource_refs,
            provider_metadata=provider_meta,
            container_metadata=container_meta,
        )


class SqliteChangeSetStore:
    """SQLite-backed ``ChangeSetStore`` implementation.

    Satisfies the ``ChangeSetStore`` protocol from
    ``cleveragents.domain.models.core.change``.
    """

    def __init__(
        self,
        session_factory: Callable[[], Session],
    ) -> None:
        if session_factory is None:
            raise ValueError("session_factory must not be None")
        self._entry_repo = ChangeSetEntryRepository(session_factory)
        self._plan_map: dict[str, str] = {}

    def start(self, plan_id: str) -> str:
        """Create a new empty ChangeSet for *plan_id*."""
        if not plan_id:
            raise ValueError("plan_id must not be empty")
        changeset_id = str(ULID())
        self._plan_map[changeset_id] = plan_id
        return changeset_id

    def record(
        self,
        changeset_id: str,
        entry: ChangeEntry,
    ) -> None:
        """Persist *entry* under the identified changeset."""
        if not changeset_id:
            raise ValueError("changeset_id must not be empty")
        self._entry_repo.save_entry(changeset_id, entry)

    def get(self, changeset_id: str) -> SpecChangeSet | None:
        """Retrieve a ChangeSet by ID, or ``None``."""
        if not changeset_id:
            return None

        entries = self._entry_repo.get_entries_for_changeset(
            changeset_id,
        )
        if not entries:
            plan_id = self._plan_map.get(changeset_id, "")
            if not plan_id:
                return None
            return SpecChangeSet(
                changeset_id=changeset_id,
                plan_id=plan_id,
            )

        plan_id = entries[0].plan_id
        return SpecChangeSet(
            changeset_id=changeset_id,
            plan_id=plan_id,
            entries=entries,
        )

    def get_for_plan(
        self,
        plan_id: str,
    ) -> list[SpecChangeSet]:
        """Return all ChangeSets associated with *plan_id*."""
        if not plan_id:
            return []

        entries = self._entry_repo.get_entries_for_plan(plan_id)
        if not entries:
            return []

        return [
            SpecChangeSet(plan_id=plan_id, entries=entries),
        ]

    def summarize(
        self,
        changeset_id: str,
    ) -> dict[str, Any]:
        """Return summary counts for a changeset."""
        cs = self.get(changeset_id)
        if cs is None:
            return {}
        return cs.summary()

    def delete_for_plan(self, plan_id: str) -> int:
        """Delete all changeset entries for a plan."""
        if not plan_id:
            raise ValueError("plan_id must not be empty")
        return self._entry_repo.delete_for_plan(plan_id)
