from __future__ import annotations

from typing import ClassVar, Optional, Type, TypeVar

from pydantic import BaseModel, PrivateAttr

from sqler import registry
from sqler.db.sqler_db import SQLerDB
from sqler.exceptions import NotBoundError
from sqler.models.queryset import SQLerQuerySet
from sqler.query import SQLerExpression

TModel = TypeVar("TModel", bound="SQLerModel")


def _pluralize(word: str) -> str:
    """Pluralize a word using common English rules.

    Handles:
    - Words ending in consonant + y → ies (category → categories)
    - Words ending in s, x, z, ch, sh → es (box → boxes)
    - Regular words → s (user → users)

    For irregular plurals (person, child, etc.), use __tablename__.
    """
    w = word.lower()
    if w.endswith("s"):
        return w  # Already plural or ends in s
    if w.endswith("y") and len(w) > 1 and w[-2] not in "aeiou":
        return w[:-1] + "ies"  # category → categories
    if w.endswith(("s", "x", "z", "ch", "sh")):
        return w + "es"  # box → boxes, class → classes
    return w + "s"  # user → users


def _default_table_name(name: str) -> str:
    """Generate default table name from class name.

    Examples:
        User → users
        Category → categories
        Address → addresses
        As → as_tbl (SQL reserved word)
    """
    lower = name.lower()
    # Check reserved words BEFORE pluralization (for words like "by", "as")
    # Also include words that pluralize to reserved words (a → as)
    reserved = {"a", "as", "by", "and", "or", "not", "null", "index", "table"}
    if lower in reserved:
        return lower + "_tbl"
    return _pluralize(name)


class SQLerModel(BaseModel):
    """Pydantic-based model with persistence helpers for SQLerDB.

    Define subclasses to model your domain. Bind the class to a database via
    :meth:`set_db`, optionally overriding the table name. Instances persist as
    JSON (excluding the private ``_id`` attribute) into a table with schema
    ``(_id INTEGER PRIMARY KEY AUTOINCREMENT, data JSON NOT NULL)``.
    """

    # internal id stored outside the JSON blob
    _id: Optional[int] = PrivateAttr(default=None)
    # snapshot of last-loaded document (for merge strategies in perf mode)
    _snapshot: Optional[dict] = PrivateAttr(default=None)

    # class-bound db + table metadata
    _db: ClassVar[Optional[SQLerDB]] = None
    _table: ClassVar[Optional[str]] = None

    # ----- class config -----
    model_config = {
        "extra": "ignore",
        "frozen": False,
    }

    # ----- class methods -----
    @classmethod
    def set_db(cls: Type[TModel], db: SQLerDB, table: Optional[str] = None) -> None:
        """Bind this model class to a database and table.

        Args:
            db: Database instance to use for persistence.
            table: Optional table name. Defaults to lowercase plural of the
                class name (e.g., ``User`` → ``users``).
        """
        explicit = getattr(cls, "__tablename__", None)
        chosen = table or explicit or _default_table_name(cls.__name__)
        cls._db = db
        cls._table = chosen
        cls.__tablename__ = chosen
        cls._db._ensure_table(cls._table)
        registry.register(cls._table, cls)

    @classmethod
    def db(cls: Type[TModel]) -> SQLerDB:
        """Return the bound database for this model."""
        db, _ = cls._require_binding()
        return db

    # ergonomic relation field builder
    @classmethod
    def ref(cls, name: str):
        """Return a model-aware field builder for a related field name.

        Usage: User.ref("address").field("city") == "Kyoto"
        """
        from .model_field import SQLerModelField

        class _RefBuilder:
            def __init__(self, model_cls, base: str):
                self.model_cls = model_cls
                self.path = [base]

            def field(self, *parts: str) -> SQLerModelField:
                return SQLerModelField(self.model_cls, self.path + list(parts))

            def any(self) -> "_RefAnyBuilder":
                return _RefAnyBuilder(self.model_cls, self.path)

        class _RefAnyBuilder(_RefBuilder):
            def field(self, *parts: str) -> SQLerModelField:
                return SQLerModelField(self.model_cls, self.path + list(parts), array_any=True)

        return _RefBuilder(cls, name)

    @classmethod
    def _require_binding(cls) -> tuple[SQLerDB, str]:
        """Return the bound DB and table or raise if unbound.

        Raises:
            NotBoundError: If :meth:`set_db` has not been called.
        """
        if cls._db is None or cls._table is None:
            raise NotBoundError(
                f"Model {cls.__name__} is not bound. Call set_db(db, table?) first.",
                details={"model": cls.__name__},
            )
        return cls._db, cls._table

    @classmethod
    def from_id(cls: Type[TModel], id_: int) -> Optional[TModel]:
        """Hydrate an instance by ``_id``.

        Args:
            id_: Row id to load.

        Returns:
            Model instance when found, else ``None``.
        """
        db, table = cls._require_binding()
        doc = db.find_document(table, id_)
        if doc is None:
            return None
        doc = cls._resolve_relations(doc)
        inst = cls.model_validate(doc)  # type: ignore[call-arg]
        # attach db id stored outside the json payload
        inst._id = doc.get("_id")
        return inst  # type: ignore[return-value]

    @classmethod
    def from_ids(cls: Type[TModel], ids: list[int]) -> list[TModel]:
        """Hydrate multiple instances by id list (batch operation).

        Fetches all documents in a single query and resolves all relations
        in batch (avoiding N+1 queries). This is much faster than looping
        over ``from_id()`` for multiple records.

        Args:
            ids: List of row ids to load.

        Returns:
            List of model instances (in same order as input ids).
            Missing ids are silently omitted from result.
        """
        if not ids:
            return []
        db, table = cls._require_binding()
        docs = db.find_documents(table, ids)
        if not docs:
            return []
        # Use queryset's batch resolution for efficient nested ref loading
        qs = cls.query()
        docs = qs._batch_resolve(docs)
        instances = []
        for doc in docs:
            inst = cls.model_validate(doc)
            inst._id = doc.get("_id")
            instances.append(inst)
        return instances

    @classmethod
    def count(cls: Type[TModel]) -> int:
        """Return total count of rows in this model's table.

        Shorthand for ``cls.query().count()``.
        """
        return cls.query().count()

    @classmethod
    def query(cls: Type[TModel]) -> SQLerQuerySet[TModel]:
        """Return a queryset for chaining and execution."""
        db, table = cls._require_binding()
        q = db.query(table)
        return SQLerQuerySet[TModel](cls, q)

    @classmethod
    def filter(cls: Type[TModel], expression: SQLerExpression) -> SQLerQuerySet[TModel]:
        """Shorthand for ``cls.query().filter(expression)``."""
        return cls.query().filter(expression)

    @classmethod
    def add_index(
        cls,
        field: str,
        *,
        unique: bool = False,
        name: Optional[str] = None,
        where: Optional[str] = None,
    ) -> None:
        """Create an index on a JSON field via the model class.

        Args:
            field: Dotted JSON path or literal column.
            unique: Enforce uniqueness.
            name: Optional index name.
            where: Optional partial-index WHERE clause.
        """
        db, table = cls._require_binding()
        db.create_index(table, field, unique=unique, name=name, where=where)

    @classmethod
    def ensure_index(
        cls,
        field: str,
        *,
        unique: bool = False,
        name: Optional[str] = None,
        where: Optional[str] = None,
    ) -> None:
        """Ensure an index on a JSON path or literal column exists (idempotent)."""
        cls.add_index(field, unique=unique, name=name, where=where)

    # ----- instance methods -----
    def save(self: TModel) -> TModel:
        """Insert or update this instance and update ``_id``.

        Returns:
            self: The same instance (for chaining).
        """
        cls = self.__class__
        db, table = cls._require_binding()
        payload = self._dump_with_relations()
        new_id = db.upsert_document(table, self._id, payload)
        self._id = new_id
        return self

    def delete(self) -> None:
        """Delete this instance by ``_id`` and unset it.

        Deprecated: prefer delete(on_delete=...) to control integrity behavior.
        """
        self.delete_with_policy()

    def delete_with_policy(self, *, on_delete: str = "restrict") -> None:
        """Delete this instance with a specified integrity policy.

        Args:
            on_delete: One of "restrict", "set_null", or "cascade".
        """
        cls = self.__class__
        db, table = cls._require_binding()
        if self._id is None:
            raise ValueError("Cannot delete unsaved model (missing _id)")
        target = (table, int(self._id))
        if on_delete not in {"restrict", "set_null", "cascade"}:
            raise ValueError("on_delete must be 'restrict','set_null', or 'cascade'")

        # find referrers
        referrers = self._find_referrers(db, table, int(self._id))
        if on_delete == "restrict" and referrers:
            from . import ReferentialIntegrityError as _RefErr

            raise _RefErr(
                f"Cannot delete {table}:{self._id}; referenced by {len(referrers)} row(s)"
            )
        if on_delete == "set_null":
            self._set_null_referrers(db, table, int(self._id), referrers)
        elif on_delete == "cascade":
            visited: set[tuple[str, int]] = {target}
            self._cascade_delete(db, referrers, visited)

        db.delete_document(table, self._id)
        self._id = None

    def refresh(self: TModel) -> TModel:
        """Reload this instance's fields from the database.

        Raises:
            ValueError: If the instance has not been saved.
            LookupError: If the row no longer exists.

        Returns:
            self: The same instance (for chaining).
        """
        cls = self.__class__
        db, table = cls._require_binding()
        if self._id is None:
            raise ValueError("Cannot refresh unsaved model (missing _id)")
        doc = db.find_document(table, self._id)
        if doc is None:
            raise LookupError(f"Row {self._id} not found for refresh")
        doc = cls._resolve_relations(doc)
        fresh = cls.model_validate(doc)  # type: ignore[call-arg]
        # update fields in-place (excluding _id which is also present)
        for fname in self.__class__.model_fields:
            if fname == "_id":
                continue
            setattr(self, fname, getattr(fresh, fname))
        # set db id explicitly
        self._id = doc.get("_id")
        return self

    # ----- ref integrity utils -----
    @classmethod
    def _find_referrers(
        cls, db: SQLerDB, target_table: str, target_id: int
    ) -> list[tuple[str, int, dict]]:
        """Find all rows across all tables that reference the target row."""
        from .integrity import find_referrers

        return find_referrers(db, target_table, target_id)

    @classmethod
    def _find_ref_paths(cls, data: dict, allowed_tables: set[str], target_id: int) -> list[str]:
        """Recursively find JSON paths containing references to a target row."""
        from .integrity import find_ref_paths

        return find_ref_paths(data, allowed_tables, target_id)

    @classmethod
    def _set_null_referrers(
        cls, db: SQLerDB, target_table: str, target_id: int, referrers: list[tuple[str, int, dict]]
    ) -> None:
        """Nullify all references to the target row in referring documents."""
        from .integrity import set_null_referrers

        set_null_referrers(db, target_table, target_id, referrers)

    @classmethod
    def _cascade_delete(
        cls, db: SQLerDB, referrers: list[tuple[str, int, dict]], visited: set[tuple[str, int]]
    ) -> None:
        """Recursively delete referring rows and their dependents."""
        from .integrity import cascade_delete

        cascade_delete(db, referrers, visited)

    # ----- reference validation -----
    @classmethod
    def validate_references(cls):
        """Validate all references across all tables in the database.

        Returns:
            List of BrokenRef instances describing dangling references.
        """
        from .integrity import validate_references

        db, _ = cls._require_binding()
        return validate_references(db)

    # ----- relationship encoding/decoding -----
    @classmethod
    def _is_ref_dict(cls, value: object) -> bool:
        return isinstance(value, dict) and "_table" in value and "_id" in value

    @classmethod
    def _resolve_relations(cls, data: dict) -> dict:
        def decode(value: object):
            if isinstance(value, dict):
                if cls._is_ref_dict(value):
                    table = value.get("_table")
                    rid = value.get("_id")
                    mdl = registry.resolve(table) if isinstance(table, str) else None
                    if mdl is not None and hasattr(mdl, "from_id"):
                        try:
                            return mdl.from_id(rid)
                        except (LookupError, RuntimeError, ValueError):
                            # Return raw ref if hydration fails
                            return value
                return {k: decode(v) for k, v in value.items()}
            if isinstance(value, list):
                return [decode(v) for v in value]
            return value

        return {k: decode(v) for k, v in data.items()}

    def _dump_with_relations(self) -> dict:
        from datetime import date, datetime

        visited: set[tuple[str, int]] = set()

        def encode(value: object):
            from sqler.models.model import SQLerModel
            from sqler.models.ref import as_ref

            if isinstance(value, SQLerModel):
                # avoid recursion: require saved child
                if value._id is None:
                    raise ValueError("Related model must be saved before saving parent")
                if (value.__class__._table, int(value._id)) not in visited:  # type: ignore[attr-defined]
                    visited.add((value.__class__._table, int(value._id)))  # type: ignore[attr-defined]
                return as_ref(value)
            # already a ref dict: validate minimally
            if isinstance(value, dict) and "_table" in value and "_id" in value:
                return {"_table": value["_table"], "_id": value["_id"]}
            if isinstance(value, list):
                return [encode(v) for v in value]
            if isinstance(value, dict):
                return {k: encode(v) for k, v in value.items()}
            if isinstance(value, BaseModel):
                return value.model_dump()
            # Handle datetime/date serialization
            if isinstance(value, datetime):
                return value.isoformat()
            if isinstance(value, date):
                return value.isoformat()
            return value

        payload: dict = {}
        for name in self.__class__.model_fields:
            if name == "_id":
                continue
            payload[name] = encode(getattr(self, name))
        return payload
