"""Module repository.

Provides typed CRUD operations for Module entities.
Handles JSON serialization/deserialization for category_relevance field.
"""

from __future__ import annotations

import json
import sqlite3
from datetime import UTC, datetime

from pydantic import TypeAdapter

from unfamiliar.constants import InvestigationCategory
from unfamiliar.db.connection import DatabaseError
from unfamiliar.models import Module

# Type adapter for parsing database rows into models
_module_adapter = TypeAdapter(Module)


class ModuleRepository:
    """Repository for Module entities.

    Handles JSON round-trip for category_relevance field:
    - Stored as json.dumps([str(c) for c in cats])
    - Deserialized as [InvestigationCategory(v) for v in json.loads(text)]
    """

    def __init__(self, conn: sqlite3.Connection) -> None:
        """Initialize repository with a database connection.

        Args:
            conn: A sqlite3.Connection with Row factory enabled.
        """
        self._conn = conn

    def create(self, module: Module) -> Module:
        """Insert a new module and return it with id populated.

        Args:
            module: A Module without an id.

        Returns:
            The Module with id and timestamps set.

        Raises:
            DatabaseError: On insert failure.
        """
        now = datetime.now(UTC).isoformat()
        category_json = _serialize_categories(module.category_relevance)

        try:
            cursor = self._conn.execute(
                """
                INSERT INTO module (
                    campaign_id, name, path, description, file_count,
                    category_relevance, security_relevant, created_at, updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    module.campaign_id,
                    module.name,
                    module.path,
                    module.description,
                    module.file_count,
                    category_json,
                    1 if module.security_relevant else 0,
                    now,
                    now,
                ),
            )
            self._conn.commit()
            return _module_adapter.validate_python(
                {
                    **module.model_dump(),
                    "id": cursor.lastrowid,
                    "created_at": now,
                    "updated_at": now,
                }
            )
        except sqlite3.Error as exc:
            raise DatabaseError("Failed to create module") from exc

    def create_many(self, modules: list[Module]) -> list[Module]:
        """Bulk insert modules and return them with ids populated.

        Inserts multiple records in a single transaction (N round-trips,
        not batch insert due to SQLite RETURNING limitations)

        Args:
            modules: List of Modules without ids.

        Returns:
            List of Modules with ids and timestamps set.

        Raises:
            DatabaseError: On insert failure.
        """
        if not modules:
            return []

        now = datetime.now(UTC).isoformat()
        results = []

        try:
            for m in modules:
                cursor = self._conn.execute(
                    """
                INSERT INTO module (
                    campaign_id, name, path, description, file_count,
                    category_relevance, security_relevant, created_at, updated_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                    RETURNING id, created_at, updated_at
                """,
                    (
                        m.campaign_id,
                        m.name,
                        m.path,
                        m.description,
                        m.file_count,
                        _serialize_categories(m.category_relevance),
                        1 if m.security_relevant else 0,
                        now,
                        now,
                    ),
                )
                row = cursor.fetchone()
                results.append(
                    Module(
                        id=row["id"],
                        campaign_id=m.campaign_id,
                        name=m.name,
                        path=m.path,
                        description=m.description,
                        file_count=m.file_count,
                        category_relevance=m.category_relevance,
                        security_relevant=m.security_relevant,
                        created_at=row["created_at"],
                        updated_at=row["updated_at"],
                    )
                )
            self._conn.commit()
        except sqlite3.Error as exc:
            self._conn.rollback()
            raise DatabaseError("Failed to create modules") from exc
        return results

    def get(self, module_id: int) -> Module | None:
        """Fetch a module by id.

        Args:
            module_id: The module's primary key.

        Returns:
            The Module or None if not found.
        """
        row = self._conn.execute(
            """
            SELECT id, campaign_id, name, path, description, file_count,
                   category_relevance, security_relevant, created_at, updated_at
            FROM module WHERE id = ?
            """,
            (module_id,),
        ).fetchone()
        return _row_to_module(row) if row else None

    def get_by_campaign(self, campaign_id: int) -> list[Module]:
        """Fetch all modules for a campaign.

        Args:
            campaign_id: The parent campaign's id.

        Returns:
            List of Modules (may be empty).
        """
        rows = self._conn.execute(
            """
            SELECT id, campaign_id, name, path, description, file_count,
                   category_relevance, security_relevant, created_at, updated_at
            FROM module WHERE campaign_id = ? ORDER BY path
            """,
            (campaign_id,),
        ).fetchall()
        return [_row_to_module(row) for row in rows]

    def get_by_path(self, campaign_id: int, path: str) -> Module | None:
        """Fetch a module by its path within a campaign.

        Args:
            campaign_id: The parent campaign's id.
            path: The module's path.

        Returns:
            The Module or None if not found.
        """
        row = self._conn.execute(
            """
            SELECT id, campaign_id, name, path, description, file_count,
                   category_relevance, security_relevant, created_at, updated_at
            FROM module WHERE campaign_id = ? AND path = ?
            """,
            (campaign_id, path),
        ).fetchone()
        return _row_to_module(row) if row else None

    def update(self, module: Module) -> None:
        """Update an existing module.

        Args:
            module: A Module with id set.

        Raises:
            DatabaseError: If module.id is None or module not found.
        """
        if module.id is None:
            raise DatabaseError("Cannot update module without id")

        now = datetime.now(UTC).isoformat()
        category_json = _serialize_categories(module.category_relevance)

        try:
            cursor = self._conn.execute(
                """
                UPDATE module SET
                    name = ?, path = ?, description = ?, file_count = ?,
                    category_relevance = ?, security_relevant = ?, updated_at = ?
                WHERE id = ?
                """,
                (
                    module.name,
                    module.path,
                    module.description,
                    module.file_count,
                    category_json,
                    1 if module.security_relevant else 0,
                    now,
                    module.id,
                ),
            )
            self._conn.commit()
            if cursor.rowcount == 0:
                raise DatabaseError(f"No module found with id: {module.id}")
        except sqlite3.Error as exc:
            raise DatabaseError("Failed to update module") from exc


def _serialize_categories(categories: list[InvestigationCategory]) -> str:
    """Serialize a list of InvestigationCategory to JSON string."""
    return json.dumps([str(c) for c in categories])


def _deserialize_categories(json_str: str | None) -> list[InvestigationCategory]:
    """Deserialize JSON string to list of InvestigationCategory."""
    if not json_str:
        return []
    values = json.loads(json_str)
    return [InvestigationCategory(v) for v in values]


def _row_to_module(row: sqlite3.Row) -> Module:
    """Convert a database row to a Module model."""
    return _module_adapter.validate_python(
        {
            "id": row["id"],
            "campaign_id": row["campaign_id"],
            "name": row["name"],
            "path": row["path"],
            "description": row["description"],
            "file_count": row["file_count"],
            "category_relevance": _deserialize_categories(row["category_relevance"]),
            "security_relevant": bool(row["security_relevant"]),
            "created_at": row["created_at"],
            "updated_at": row["updated_at"],
        }
    )
