"""Database persistence helpers for repository indexing.

Extracted from :mod:`~cleveragents.application.services.repo_indexing_service`
to keep the service module under the 500-line limit (CONTRIBUTING.md line 396).

Functions accept a ``session_factory`` callable (matching the DI pattern
used elsewhere in the codebase) and handle session lifecycle internally.
"""

from __future__ import annotations

import logging
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast

from cleveragents.domain.models.core.repo_index import (
    FileRecord,
    IndexMetadata,
    IndexStatus,
    RepoIndex,
)
from cleveragents.infrastructure.database.models import (
    IndexedFileModel,
    RepoIndexModel,
)

if TYPE_CHECKING:
    from sqlalchemy.orm import Session

__all__ = [
    "load_index",
    "load_index_status",
    "persist_index",
    "persist_status",
]

logger = logging.getLogger(__name__)

# Batch size for bulk file-record inserts (G4 fix).
_INSERT_BATCH_SIZE = 500


def _safe_fromisoformat(value: str) -> datetime:
    """Parse an ISO-8601 timestamp, falling back to UTC now on error (G3 fix).

    Returned datetime is always UTC-aware: naive results get UTC attached,
    and aware non-UTC datetimes are converted via ``astimezone(UTC)``.
    """
    try:
        dt = datetime.fromisoformat(value)
    except (ValueError, TypeError):
        logger.warning(
            "Corrupt timestamp in database, using UTC now as fallback",
            extra={"raw_value": value[:80] if value else ""},
        )
        return datetime.now(tz=UTC)
    if dt.tzinfo is None:
        return dt.replace(tzinfo=UTC)
    return dt.astimezone(UTC)


def _session(session_factory: Any) -> Session:
    """Obtain a database session from the factory."""
    return session_factory()


def persist_status(
    session_factory: Any,
    *,
    index_id: str,
    resource_id: str,
    status: IndexStatus,
    error_message: str | None = None,
) -> None:
    """Persist or update just the index status row (no file records).

    Used to record ``INDEXING`` before the walk starts and ``ERROR``
    on failure, ensuring the lifecycle is observable.
    """
    if error_message is not None and status != IndexStatus.ERROR:
        raise ValueError("error_message must be None when status is not ERROR")

    session = _session(session_factory)
    try:
        now_iso = datetime.now(tz=UTC).isoformat()

        existing = (
            session.query(RepoIndexModel).filter_by(resource_id=resource_id).first()
        )
        if existing is not None:
            # Manual child-row deletion required because SQLite does not
            # honour FK ON DELETE CASCADE without PRAGMA foreign_keys=ON.
            session.query(IndexedFileModel).filter_by(
                index_id=existing.index_id
            ).delete()
            session.delete(existing)
            session.flush()

        db_index = RepoIndexModel(
            index_id=index_id,
            resource_id=resource_id,
            file_count=0,
            token_estimate=0,
            primary_language="unknown",
            status=status.value,
            error_message=error_message,
            indexed_at=now_iso,
            created_at=now_iso,
        )
        session.add(db_index)
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


def persist_index(session_factory: Any, repo_index: RepoIndex) -> None:
    """Persist a RepoIndex to the database (upsert semantics).

    Deletes any existing index for the same resource_id, then inserts
    the new index metadata and all file records in bulk.
    """
    meta = repo_index.metadata
    session = _session(session_factory)
    try:
        # Remove existing index for this resource then insert fresh rows
        # (delete-insert instead of in-place update so that the row set
        # exactly matches the new file list without orphan cleanup).
        existing = (
            session.query(RepoIndexModel)
            .filter_by(resource_id=meta.resource_id)
            .first()
        )
        if existing is not None:
            # Manual child-row deletion — see persist_status comment.
            session.query(IndexedFileModel).filter_by(
                index_id=existing.index_id
            ).delete()
            session.delete(existing)
            session.flush()

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

        # Insert index metadata
        db_index = RepoIndexModel(
            index_id=meta.index_id,
            resource_id=meta.resource_id,
            file_count=meta.file_count,
            token_estimate=meta.token_estimate,
            primary_language=meta.primary_language,
            status=meta.status.value,
            error_message=meta.error_message,
            indexed_at=meta.indexed_at.isoformat(),
            created_at=now_iso,
        )
        session.add(db_index)
        session.flush()

        # Bulk insert file records in batches to bound memory (G4 fix).
        files = repo_index.files
        for batch_start in range(0, len(files), _INSERT_BATCH_SIZE):
            batch = files[batch_start : batch_start + _INSERT_BATCH_SIZE]
            file_models = [
                IndexedFileModel(
                    index_id=meta.index_id,
                    path=fr.path,
                    content_hash=fr.content_hash,
                    token_count=fr.token_count,
                    size_bytes=fr.size_bytes,
                    language=fr.language,
                    last_modified=fr.last_modified.isoformat(),
                )
                for fr in batch
            ]
            session.add_all(file_models)
            session.flush()

        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


def load_index(session_factory: Any, resource_id: str) -> RepoIndex | None:
    """Load a full RepoIndex (metadata + files) from the database.

    Returns:
        The :class:`RepoIndex` if it exists, or ``None``.
    """
    session = _session(session_factory)
    try:
        row = session.query(RepoIndexModel).filter_by(resource_id=resource_id).first()
        if row is None:
            return None

        file_rows = (
            session.query(IndexedFileModel).filter_by(index_id=row.index_id).all()
        )

        # Build records individually; skip corrupt rows rather than
        # failing the entire load (defensive against DB corruption).
        file_list: list[FileRecord] = []
        for fr in file_rows:
            try:
                file_list.append(
                    FileRecord(
                        path=cast(str, fr.path),
                        content_hash=cast(str, fr.content_hash),
                        token_count=cast(int, fr.token_count),
                        language=cast(str, fr.language),
                        size_bytes=cast(int, fr.size_bytes),
                        last_modified=_safe_fromisoformat(cast(str, fr.last_modified)),
                    )
                )
            except (ValueError, TypeError):
                logger.warning(
                    "Skipping corrupt file record",
                    extra={"path": getattr(fr, "path", "?")},
                )
        files = tuple(file_list)

        # N13 fix: treat both None and empty string as None.
        raw_error = cast(str | None, row.error_message)
        error_msg = raw_error if raw_error else None

        metadata = IndexMetadata(
            index_id=cast(str, row.index_id),
            resource_id=cast(str, row.resource_id),
            indexed_at=_safe_fromisoformat(cast(str, row.indexed_at)),
            file_count=len(file_list),
            token_estimate=sum(f.token_count for f in file_list),
            primary_language=cast(str, row.primary_language),
            status=IndexStatus(cast(str, row.status)),
            error_message=error_msg,
            created_at=_safe_fromisoformat(cast(str, row.created_at)),
        )

        return RepoIndex(metadata=metadata, files=files)
    finally:
        session.close()


def load_index_status(session_factory: Any, resource_id: str) -> IndexMetadata | None:
    """Load only the index metadata (no file records).

    Lightweight query for status display.

    Returns:
        The :class:`IndexMetadata` if it exists, or ``None``.
    """
    session = _session(session_factory)
    try:
        row = session.query(RepoIndexModel).filter_by(resource_id=resource_id).first()
        if row is None:
            return None

        raw_error = cast(str | None, row.error_message)
        error_msg = raw_error if raw_error else None

        return IndexMetadata(
            index_id=cast(str, row.index_id),
            resource_id=cast(str, row.resource_id),
            indexed_at=_safe_fromisoformat(cast(str, row.indexed_at)),
            file_count=cast(int, row.file_count),
            token_estimate=cast(int, row.token_estimate),
            primary_language=cast(str, row.primary_language),
            status=IndexStatus(cast(str, row.status)),
            error_message=error_msg,
            created_at=_safe_fromisoformat(cast(str, row.created_at)),
        )
    finally:
        session.close()
