"""ORM classes for the Matchbox PostgreSQL database."""

import textwrap
from typing import Any, Optional

from sqlalchemy import (
    BIGINT,
    BOOLEAN,
    INTEGER,
    REAL,
    CheckConstraint,
    DateTime,
    Enum,
    ForeignKey,
    Identity,
    Index,
    UniqueConstraint,
    select,
    text,
)
from sqlalchemy.dialects.postgresql import BYTEA, JSONB, TEXT, insert
from sqlalchemy.orm import Mapped, Session, mapped_column, relationship, selectinload

from matchbox.common.dtos import (
    BackendResourceType,
    CollectionName,
    LocationConfig,
    ModelType,
    RunID,
    StepName,
    StepPath,
    StepType,
    UploadStage,
)
from matchbox.common.dtos import Collection as CommonCollection
from matchbox.common.dtos import ModelConfig as CommonModelConfig
from matchbox.common.dtos import ResolverConfig as CommonResolverConfig
from matchbox.common.dtos import Run as CommonRun
from matchbox.common.dtos import SourceConfig as CommonSourceConfig
from matchbox.common.dtos import SourceField as CommonSourceField
from matchbox.common.dtos import Step as CommonStep
from matchbox.common.exceptions import (
    MatchboxCollectionNotFoundError,
    MatchboxRunNotFoundError,
    MatchboxStepAlreadyExists,
    MatchboxStepNotFoundError,
    MatchboxStepTypeError,
)
from matchbox.server.base import (
    DEFAULT_GROUPS,
    DEFAULT_PERMISSIONS,
)
from matchbox.server.postgresql.db import MBDB
from matchbox.server.postgresql.mixin import CountMixin


class Collections(CountMixin, MBDB.MatchboxBase):
    """Named collections of steps and runs."""

    __tablename__ = "collections"

    collection_id: Mapped[int] = mapped_column(
        BIGINT, primary_key=True, autoincrement=True
    )
    name: Mapped[str] = mapped_column(TEXT, nullable=False)

    # Relationships
    runs: Mapped[list["Runs"]] = relationship(back_populates="collection")
    permissions: Mapped[list["Permissions"]] = relationship(
        back_populates="collection",
        passive_deletes=True,
    )

    # Constraints
    __table_args__ = (UniqueConstraint("name", name="collections_name_key"),)

    @classmethod
    def from_name(
        cls,
        name: CollectionName,
        session: Session | None = None,
    ) -> "Collections":
        """Resolve a collection name to a Collections object.

        Args:
            name: The name of the collection to resolve.
            session: Optional session to use for the query.

        Raises:
            MatchboxCollectionNotFoundError: If the collection doesn't exist.
        """
        query = select(cls).where(cls.name == name).options(selectinload(cls.runs))

        if session:
            collection = session.execute(query).scalar_one_or_none()
        else:
            with MBDB.get_session() as session:
                collection = session.execute(query).scalar_one_or_none()

        if not collection:
            raise MatchboxCollectionNotFoundError(f"Collection '{name}' not found.")

        return collection

    def to_dto(self) -> CommonCollection:
        """Convert ORM collection to a matchbox.common Collection object."""
        run_ids: list[RunID] = []
        default_run = None
        if runs := self.runs:
            run_ids = [r.run_id for r in runs]
            default_run_list = [r.run_id for r in runs if r.is_default]
            if default_run_list:
                default_run = default_run_list[0]

        return CommonCollection(runs=run_ids, default_run=default_run)


class Runs(CountMixin, MBDB.MatchboxBase):
    """Runs of collections of steps."""

    __tablename__ = "runs"

    run_id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
    collection_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("collections.collection_id", ondelete="CASCADE"),
        nullable=False,
    )
    is_mutable: Mapped[bool] = mapped_column(BOOLEAN, default=False, nullable=True)
    is_default: Mapped[bool] = mapped_column(BOOLEAN, default=False, nullable=True)

    # Relationships
    collection: Mapped["Collections"] = relationship(back_populates="runs")
    steps: Mapped[list["Steps"]] = relationship(back_populates="run")

    # Constraints
    __table_args__ = (
        UniqueConstraint("collection_id", "run_id", name="unique_run_id"),
        Index(
            "ix_default_run_collection",
            "collection_id",
            unique=True,
            postgresql_where=text("is_default = true"),
        ),
    )

    @classmethod
    def from_id(
        cls,
        collection: CollectionName,
        run_id: RunID,
        session: Session | None = None,
    ) -> "Runs":
        """Resolve a collection and run name to a Runs object.

        Args:
            collection: The name of the collection containing the run.
            run_id: The ID of the run within that collection.
            session: Optional session to use for the query.

        Raises:
            MatchboxRunNotFoundError: If the run doesn't exist.
        """
        query = (
            select(cls)
            .where(cls.run_id == run_id)
            .options(
                selectinload(cls.steps)
                .selectinload(Steps.source_config)
                .selectinload(SourceConfigs.fields),
                selectinload(cls.steps).selectinload(Steps.model_config),
                selectinload(cls.steps).selectinload(Steps.resolver_config),
                selectinload(cls.collection),
            )
        )

        if session:
            run_orm = session.execute(query).scalar_one_or_none()
        else:
            with MBDB.get_session() as session:
                run_orm = session.execute(query).scalar_one_or_none()

        if not run_orm:
            raise MatchboxRunNotFoundError

        if run_orm.collection.name != collection:
            raise MatchboxRunNotFoundError(
                run_id=id,
                message=f"Run {id} not found in collection {collection}",
            )

        return run_orm

    def to_dto(self) -> CommonRun:
        """Convert ORM run to a matchbox.common Run object."""
        steps: dict[StepName, CommonStep] = {}
        if self.steps:
            steps = {step.name: step.to_dto() for step in self.steps}

        return CommonRun(
            run_id=self.run_id,
            is_default=self.is_default,
            is_mutable=self.is_mutable,
            steps=steps,
        )


class StepFrom(CountMixin, MBDB.MatchboxBase):
    """Step lineage closure table."""

    __tablename__ = "step_from"

    # Columns
    parent: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        primary_key=True,
    )
    child: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        primary_key=True,
    )
    level: Mapped[int] = mapped_column(INTEGER, nullable=False, primary_key=True)

    # Constraints
    __table_args__ = (
        CheckConstraint("parent != child", name="no_self_reference"),
        CheckConstraint("level > 0", name="positive_level"),
    )


class Steps(CountMixin, MBDB.MatchboxBase):
    """Table of steps corresponding to models, resolvers, and sources.

    Models produce edges and resolvers produce cluster assignments.
    """

    __tablename__ = "steps"

    # Columns
    step_id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
    run_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("runs.run_id", ondelete="CASCADE"), nullable=False
    )
    upload_stage: Mapped[UploadStage] = mapped_column(
        Enum(UploadStage, native_enum=True, name="upload_stages", schema="mb"),
        nullable=False,
        default=UploadStage.READY,
    )
    name: Mapped[str] = mapped_column(TEXT, nullable=False)
    description: Mapped[str | None] = mapped_column(TEXT, nullable=True)
    type: Mapped[str] = mapped_column(TEXT, nullable=False)
    fingerprint: Mapped[bytes] = mapped_column(BYTEA, nullable=False)

    # Relationships
    source_config: Mapped[Optional["SourceConfigs"]] = relationship(
        back_populates="source_step", uselist=False
    )
    model_config: Mapped[Optional["ModelConfigs"]] = relationship(
        back_populates="model_step", uselist=False
    )
    resolver_config: Mapped[Optional["ResolverConfigs"]] = relationship(
        back_populates="resolver_step", uselist=False
    )
    model_edges: Mapped[list["ModelEdges"]] = relationship(
        back_populates="proposed_by",
        passive_deletes=True,
    )
    resolver_clusters: Mapped[list["ResolverClusters"]] = relationship(
        back_populates="proposed_by",
        passive_deletes=True,
    )
    parents: Mapped[list["Steps"]] = relationship(
        secondary=StepFrom.__table__,
        primaryjoin=textwrap.dedent("""\
            and_(
                Steps.step_id == StepFrom.child,
                StepFrom.level == 1
            )
        """),
        secondaryjoin="Steps.step_id == StepFrom.parent",
        viewonly=True,
        order_by="StepFrom.parent",
    )
    run: Mapped["Runs"] = relationship(back_populates="steps")

    # Constraints
    __table_args__ = (
        CheckConstraint(
            "type IN ('model', 'source', 'resolver')",
            name="step_type_constraints",
        ),
        UniqueConstraint("run_id", "name", name="steps_name_key"),
    )

    @property
    def ancestors(self) -> set["Steps"]:
        """Return all ancestors (parents, grandparents, etc.) of this step."""
        with MBDB.get_session() as session:
            ancestor_query = (
                select(Steps)
                .select_from(Steps)
                .join(StepFrom, Steps.step_id == StepFrom.parent)
                .where(StepFrom.child == self.step_id)
            )
            return set(session.execute(ancestor_query).scalars().all())

    @property
    def descendants(self) -> set["Steps"]:
        """Return descendants (children, grandchildren, etc.) of this step."""
        with MBDB.get_session() as session:
            descendant_query = (
                select(Steps)
                .select_from(Steps)
                .join(StepFrom, Steps.step_id == StepFrom.child)
                .where(StepFrom.parent == self.step_id)
            )
            return set(session.execute(descendant_query).scalars().all())

    def get_lineage(
        self,
        sources: list["SourceConfigs"] | None = None,
        queryable_only: bool = False,
    ) -> list[tuple[int, int | None]]:
        """Returns lineage ordered by priority.

        Highest priority (lowest level) first, then by step_id for stability.

        Args:
            sources: If provided, only return lineage paths that lead to these sources
            queryable_only: If true, only include queryable step types

        Returns:
            List of tuples (step_id, source_config_id) ordered by priority.
        """
        with MBDB.get_session() as session:
            query = (
                select(
                    StepFrom.parent,
                    SourceConfigs.source_config_id,
                )
                .join(
                    Steps,
                    StepFrom.parent == Steps.step_id,
                )
                .join(
                    SourceConfigs,
                    StepFrom.parent == SourceConfigs.step_id,
                    isouter=True,
                )
                .where(StepFrom.child == self.step_id)
            )

            if queryable_only:
                query = query.where(
                    Steps.type.in_(
                        [
                            StepType.SOURCE.value,
                            StepType.RESOLVER.value,
                        ]
                    )
                )

            if sources:
                # Filter by source configs
                source_step_ids = [sc.step_id for sc in sources]

                descendant_ids = (
                    session.execute(
                        select(StepFrom.child).where(
                            StepFrom.parent.in_(source_step_ids)
                        )
                    )
                    .scalars()
                    .all()
                )

                query = query.where(
                    StepFrom.parent.in_(source_step_ids + descendant_ids)
                )

            results = session.execute(
                query.order_by(StepFrom.level.asc(), StepFrom.parent.asc())
            ).all()

            # Get self's source config ID
            self_source_config_id = (
                self.source_config.source_config_id if self.source_config else None
            )

            # Add self at beginning (highest priority - level 0)
            lineage_self = []
            if not queryable_only or self.type in {
                StepType.SOURCE.value,
                StepType.RESOLVER.value,
            }:
                lineage_self = [(self.step_id, self_source_config_id)]

            return lineage_self + list(results)

    @classmethod
    def from_path(
        cls,
        path: StepPath,
        res_type: StepType | None = None,
        session: Session | None = None,
        for_update: bool = False,
    ) -> "Steps":
        """Resolve a step path to a Step ORM object.

        Args:
            path: The path of the step to resolve.
            res_type: A step type to use as filter.
            session: A session to get the step for updates.
            for_update: Locks the row until updated.

        Raises:
            MatchboxStepNotFoundError: If the step doesn't exist.
        """
        query = (
            select(cls)
            .join(cls.run)
            .join(Runs.collection)
            .where(
                cls.name == path.name,
                Runs.run_id == path.run,
                Collections.name == path.collection,
            )
        )

        if res_type:
            query = query.where(cls.type == res_type.value)

        if for_update:
            query = query.with_for_update(nowait=True)

        if session:
            step = session.execute(query).scalar()
        else:
            with MBDB.get_session() as session:
                step = session.execute(query).scalar()

        if step:
            return step

        raise MatchboxStepNotFoundError(
            message=f"No step {path} of type {res_type or 'any'}."
        )

    @classmethod
    def from_dto(cls, step: CommonStep, path: StepPath, session: Session) -> "Steps":
        """Create a Steps instance from a Step DTO object.

        The step will be added to the session and flushed (but not committed).

        For model steps, lineage entries will be created automatically.

        Args:
            step: The Step DTO to convert
            path: The full step path
            session: Database session (caller must commit)

        Returns:
            A Steps ORM instance with ID and relationships established
        """
        # Find the run ID for the given collection and run ID
        run_obj = session.execute(
            select(Runs)
            .join(Collections)
            .where(Collections.name == path.collection, Runs.run_id == path.run)
        ).scalar_one_or_none()

        if not run_obj:
            raise MatchboxRunNotFoundError(run_id=path.run)

        # Attempt to insert new step
        result = session.execute(
            insert(cls)
            .values(
                run_id=run_obj.run_id,
                name=path.name,
                description=step.description,
                type=step.step_type.value,
                fingerprint=step.fingerprint,
            )
            .on_conflict_do_nothing(constraint="steps_name_key")
            .returning(cls.step_id)
        )

        step_id = result.scalar_one_or_none()
        if step_id is None:
            raise MatchboxStepAlreadyExists(f"Step {path.name} already exists")

        # Fetch the newly created step
        step_orm = session.get(cls, step_id)

        if step.step_type == StepType.SOURCE:
            step_orm.source_config = SourceConfigs.from_dto(step.config)

        elif step.step_type == StepType.MODEL:
            step_orm.model_config = ModelConfigs.from_dto(step.config)
            # Create lineage
            left_parent_path = StepPath(
                collection=path.collection,
                run=path.run,
                name=step.config.left_query.resolves_from,
            )
            left_parent = cls.from_path(
                path=left_parent_path,
                session=session,
            )
            if left_parent.type not in {
                StepType.SOURCE.value,
                StepType.RESOLVER.value,
            }:
                raise MatchboxStepTypeError(
                    step_name=str(left_parent_path),
                    step_type=StepType(left_parent.type),
                    expected_step_types=[
                        StepType.SOURCE,
                        StepType.RESOLVER,
                    ],
                )
            cls._create_closure_entries(session, step_orm, left_parent)

            if step.config.type == ModelType.LINKER:
                right_parent_path = StepPath(
                    collection=path.collection,
                    run=path.run,
                    name=step.config.right_query.resolves_from,
                )
                right_parent = cls.from_path(
                    path=right_parent_path,
                    session=session,
                )
                if right_parent.type not in {
                    StepType.SOURCE.value,
                    StepType.RESOLVER.value,
                }:
                    raise MatchboxStepTypeError(
                        step_name=str(right_parent_path),
                        step_type=StepType(right_parent.type),
                        expected_step_types=[
                            StepType.SOURCE,
                            StepType.RESOLVER,
                        ],
                    )
                cls._create_closure_entries(session, step_orm, right_parent)

        elif step.step_type == StepType.RESOLVER:
            step_orm.resolver_config = ResolverConfigs.from_dto(step.config)
            for parent_name in dict.fromkeys(step.config.inputs):
                parent_path = StepPath(
                    collection=path.collection,
                    run=path.run,
                    name=parent_name,
                )
                parent = cls.from_path(
                    path=parent_path,
                    session=session,
                )
                if parent.type != StepType.MODEL.value:
                    raise MatchboxStepTypeError(
                        message=("Resolver steps must only depend on model steps."),
                        step_name=str(parent_path),
                        step_type=StepType(parent.type),
                        expected_step_types=[
                            StepType.MODEL,
                        ],
                    )
                cls._create_closure_entries(
                    session,
                    step_orm,
                    parent,
                )

        return step_orm

    def to_dto(self) -> CommonStep:
        """Convert ORM step to a matchbox.common Step object."""
        if self.type == StepType.SOURCE:
            config = self.source_config.to_dto()
        elif self.type == StepType.MODEL:
            config = self.model_config.to_dto()
        else:
            config = self.resolver_config.to_dto()

        return CommonStep(
            description=self.description,
            step_type=StepType(self.type),
            config=config,
            fingerprint=self.fingerprint,
        )

    @staticmethod
    def _create_closure_entries(
        session: Session, child: "Steps", parent: "Steps"
    ) -> None:
        """Create closure table entries for a parent-child relationship."""
        # Direct relationship
        session.execute(
            insert(StepFrom)
            .values(parent=parent.step_id, child=child.step_id, level=1)
            .on_conflict_do_nothing()
        )

        # Transitive closure
        ancestors = (
            session.execute(select(StepFrom).where(StepFrom.child == parent.step_id))
            .scalars()
            .all()
        )

        if ancestors:
            values_to_insert: list[dict[str, int]] = [
                {
                    "parent": ancestor.parent,
                    "child": child.step_id,
                    "level": ancestor.level + 1,
                }
                for ancestor in ancestors
            ]
            session.execute(
                insert(StepFrom).values(values_to_insert).on_conflict_do_nothing()
            )


class SourceFields(CountMixin, MBDB.MatchboxBase):
    """Table for storing column details for SourceConfigs."""

    __tablename__ = "source_fields"

    # Columns
    field_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
    source_config_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("source_configs.source_config_id", ondelete="CASCADE"),
        nullable=False,
    )
    index: Mapped[int] = mapped_column(INTEGER, nullable=False)
    name: Mapped[str] = mapped_column(TEXT, nullable=False)
    type: Mapped[str] = mapped_column(TEXT, nullable=False)
    is_key: Mapped[bool] = mapped_column(BOOLEAN, nullable=False)

    # Relationships
    source_config: Mapped["SourceConfigs"] = relationship(
        back_populates="fields",
        foreign_keys=[source_config_id],
    )

    # Constraints and indices
    __table_args__ = (
        UniqueConstraint("source_config_id", "index", name="unique_index"),
        Index("ix_source_columns_source_config_id", "source_config_id"),
        Index(
            "ix_unique_key_field",
            "source_config_id",
            unique=True,
            postgresql_where=text("is_key = true"),
        ),
    )


class ClusterSourceKey(CountMixin, MBDB.MatchboxBase):
    """Table for storing source primary keys for clusters."""

    __tablename__ = "cluster_keys"

    # Columns
    key_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
    cluster_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), nullable=False
    )
    source_config_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("source_configs.source_config_id", ondelete="CASCADE"),
        nullable=False,
    )
    key: Mapped[str] = mapped_column(TEXT, nullable=False)

    # Relationships
    cluster: Mapped["Clusters"] = relationship(back_populates="keys")
    source_config: Mapped["SourceConfigs"] = relationship(back_populates="cluster_keys")

    # Constraints and indices
    __table_args__ = (
        Index("ix_cluster_keys_cluster_id", "cluster_id"),
        Index("ix_cluster_keys_keys", "key"),
        Index("ix_cluster_keys_source_config_id", "source_config_id"),
        UniqueConstraint("key_id", "source_config_id", name="unique_keys_source"),
    )


class SourceConfigs(CountMixin, MBDB.MatchboxBase):
    """Table of source_configs of data for Matchbox."""

    __tablename__ = "source_configs"

    # Columns
    source_config_id: Mapped[int] = mapped_column(
        BIGINT, Identity(start=1), primary_key=True
    )
    step_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        nullable=False,
    )
    location_type: Mapped[str] = mapped_column(TEXT, nullable=False)
    location_name: Mapped[str] = mapped_column(TEXT, nullable=False)
    extract_transform: Mapped[str] = mapped_column(TEXT, nullable=False)

    @property
    def name(self) -> str:
        """Get the name of the related step."""
        return self.source_step.name

    # Relationships
    source_step: Mapped["Steps"] = relationship(back_populates="source_config")
    fields: Mapped[list["SourceFields"]] = relationship(
        back_populates="source_config",
        passive_deletes=True,
        cascade="all, delete-orphan",
    )
    key_field: Mapped[Optional["SourceFields"]] = relationship(
        primaryjoin=(
            "and_(SourceConfigs.source_config_id == SourceFields.source_config_id, "
            "SourceFields.is_key == True)"
        ),
        viewonly=True,
        uselist=False,
    )
    index_fields: Mapped[list["SourceFields"]] = relationship(
        primaryjoin=(
            "and_(SourceConfigs.source_config_id == SourceFields.source_config_id, "
            "SourceFields.is_key == False)"
        ),
        viewonly=True,
        order_by="SourceFields.index",
        collection_class=list,
    )
    cluster_keys: Mapped[list["ClusterSourceKey"]] = relationship(
        back_populates="source_config",
        passive_deletes=True,
    )
    clusters: Mapped[list["Clusters"]] = relationship(
        secondary=ClusterSourceKey.__table__,
        primaryjoin=(
            "SourceConfigs.source_config_id == ClusterSourceKey.source_config_id"
        ),
        secondaryjoin="ClusterSourceKey.cluster_id == Clusters.cluster_id",
        viewonly=True,
    )

    def __init__(
        self,
        key_field: SourceFields | None = None,
        index_fields: list[SourceFields] | None = None,
        **kwargs: Any,
    ) -> None:
        """Initialise SourceConfigs with optional field objects."""
        super().__init__(**kwargs)

        # Add the key field and mark it as the key
        if key_field is not None:
            key_field.is_key = True
            key_field.index = 0
            self.fields.append(key_field)

        # Add index fields with proper indices
        if index_fields is not None:
            for idx, field in enumerate(index_fields):
                field.is_key = False
                field.index = idx + 1
                self.fields.append(field)

    @classmethod
    def list_all(cls) -> list["SourceConfigs"]:
        """Returns all source_configs in the database."""
        with MBDB.get_session() as session:
            return session.execute(select(cls)).scalars().all()

    @classmethod
    def from_dto(
        cls,
        config: CommonSourceConfig,
    ) -> "SourceConfigs":
        """Create a SourceConfigs instance from a Step DTO object."""
        # Create the SourceConfigs object
        return cls(
            location_type=str(config.location_config.type),
            location_name=str(config.location_config.name),
            extract_transform=config.extract_transform,
            key_field=SourceFields(
                index=0,
                name=config.key_field.name,
                type=config.key_field.type.value,
            ),
            index_fields=[
                SourceFields(
                    index=idx + 1,
                    name=field.name,
                    type=field.type.value,
                )
                for idx, field in enumerate(config.index_fields)
            ],
        )

    def to_dto(self) -> CommonSourceConfig:
        """Convert ORM source to a matchbox.common.SourceConfig object."""
        return CommonSourceConfig(
            location_config=LocationConfig(
                type=self.location_type, name=self.location_name
            ),
            extract_transform=self.extract_transform,
            key_field=CommonSourceField(
                name=self.key_field.name, type=self.key_field.type
            ),
            index_fields=[
                CommonSourceField(
                    name=field.name,
                    type=field.type,
                )
                for field in self.index_fields
            ],
        )


class ModelConfigs(CountMixin, MBDB.MatchboxBase):
    """Table of model configs for Matchbox."""

    __tablename__ = "model_configs"

    # Columns
    model_config_id: Mapped[int] = mapped_column(
        BIGINT, Identity(start=1), primary_key=True
    )
    step_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        nullable=False,
    )
    model_class: Mapped[str] = mapped_column(TEXT, nullable=False)
    model_settings: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
    left_query: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
    right_query: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)

    @property
    def name(self) -> str:
        """Get the name of the related step."""
        return self.model_step.name

    # Relationships
    model_step: Mapped["Steps"] = relationship(back_populates="model_config")

    @classmethod
    def list_all(cls) -> list["SourceConfigs"]:
        """Returns all model_configs in the database."""
        with MBDB.get_session() as session:
            return session.execute(select(cls)).scalars().all()

    @classmethod
    def from_dto(
        cls,
        config: CommonModelConfig,
    ) -> "ModelConfigs":
        """Create a SourceConfigs instance from a Step DTO object."""
        # Create the SourceConfigs object
        return cls(
            model_class=config.model_class,
            model_settings=config.model_settings,
            left_query=config.left_query.model_dump(mode="json"),
            right_query=(
                None
                if not config.right_query
                else config.right_query.model_dump(mode="json")
            ),
        )

    def to_dto(self) -> CommonModelConfig:
        """Convert ORM source to a matchbox.common.ModelConfig object."""
        return CommonModelConfig(
            type=ModelType.LINKER if self.right_query else ModelType.DEDUPER,
            model_class=self.model_class,
            model_settings=self.model_settings,
            left_query=self.left_query,
            right_query=self.right_query,
        )


class ResolverConfigs(CountMixin, MBDB.MatchboxBase):
    """Table of resolver configs for Matchbox."""

    __tablename__ = "resolver_configs"

    resolver_config_id: Mapped[int] = mapped_column(
        BIGINT, Identity(start=1), primary_key=True
    )
    step_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        nullable=False,
    )
    resolver_class: Mapped[str] = mapped_column(TEXT, nullable=False)
    resolver_settings: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)

    resolver_step: Mapped["Steps"] = relationship(back_populates="resolver_config")

    __table_args__ = (UniqueConstraint("step_id", name="resolver_configs_step_key"),)

    @classmethod
    def from_dto(
        cls,
        config: CommonResolverConfig,
    ) -> "ResolverConfigs":
        """Create a ResolverConfigs instance from a Step DTO object."""
        return cls(
            resolver_class=config.resolver_class,
            resolver_settings=config.resolver_settings,
        )

    def to_dto(self) -> CommonResolverConfig:
        """Convert ORM resolver config to a matchbox.common ResolverConfig object."""
        return CommonResolverConfig(
            resolver_class=self.resolver_class,
            resolver_settings=self.resolver_settings,
            inputs=tuple(r.name for r in self.resolver_step.parents),
        )


class Contains(CountMixin, MBDB.MatchboxBase):
    """Cluster lineage table."""

    __tablename__ = "contains"

    # Columns
    root: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), primary_key=True
    )
    leaf: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), primary_key=True
    )

    # Constraints and indices
    __table_args__ = (
        CheckConstraint("root != leaf", name="no_self_containment"),
        UniqueConstraint("root", "leaf"),
        Index("ix_contains_root_leaf", "root", "leaf"),
        Index("ix_contains_leaf_root", "leaf", "root"),
    )


class Clusters(CountMixin, MBDB.MatchboxBase):
    """Table of indexed data and clusters that match it."""

    __tablename__ = "clusters"

    # Columns
    cluster_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
    cluster_hash: Mapped[bytes] = mapped_column(BYTEA, nullable=False)

    # Relationships
    keys: Mapped[list["ClusterSourceKey"]] = relationship(
        back_populates="cluster",
        passive_deletes=True,
    )
    leaves: Mapped[list["Clusters"]] = relationship(
        secondary=Contains.__table__,
        primaryjoin="Clusters.cluster_id == Contains.root",
        secondaryjoin="Clusters.cluster_id == Contains.leaf",
        backref="roots",
    )
    # Add relationship to SourceConfigs through ClusterSourceKey
    source_configs: Mapped[list["SourceConfigs"]] = relationship(
        secondary=ClusterSourceKey.__table__,
        primaryjoin="Clusters.cluster_id == ClusterSourceKey.cluster_id",
        secondaryjoin=(
            "ClusterSourceKey.source_config_id == SourceConfigs.source_config_id"
        ),
        viewonly=True,
    )

    # Constraints and indices
    __table_args__ = (UniqueConstraint("cluster_hash", name="clusters_hash_key"),)


class UserGroups(MBDB.MatchboxBase):
    """Association table for user-group membership."""

    __tablename__ = "user_groups"

    # Columns
    user_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("users.user_id", ondelete="CASCADE"),
        primary_key=True,
    )
    group_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("groups.group_id", ondelete="CASCADE"),
        primary_key=True,
    )


class Users(CountMixin, MBDB.MatchboxBase):
    """Table of user identities."""

    __tablename__ = "users"

    # Columns
    user_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
    name: Mapped[str] = mapped_column(TEXT, nullable=False)
    email: Mapped[str] = mapped_column(TEXT, nullable=True)

    # Relationships
    judgements: Mapped[list["EvalJudgements"]] = relationship(back_populates="user")
    groups: Mapped[list["Groups"]] = relationship(
        secondary=UserGroups.__table__,
        back_populates="members",
    )

    # Constraints and indices
    __table_args__ = (UniqueConstraint("name", name="user_name_unique"),)


class Groups(CountMixin, MBDB.MatchboxBase):
    """Groups for permission management."""

    __tablename__ = "groups"

    # Columns
    group_id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
    name: Mapped[str] = mapped_column(TEXT, nullable=False)
    description: Mapped[str | None] = mapped_column(TEXT, nullable=True)
    is_system: Mapped[bool] = mapped_column(BOOLEAN, default=False, nullable=False)

    # Relationships
    members: Mapped[list["Users"]] = relationship(
        secondary=UserGroups.__table__,
        back_populates="groups",
    )
    permissions: Mapped[list["Permissions"]] = relationship(
        back_populates="group",
        passive_deletes=True,
    )

    # Constraints and indices
    __table_args__ = (UniqueConstraint("name", name="groups_name_key"),)

    @classmethod
    def initialise(cls) -> None:
        """Create standard users, groups, and permissions."""
        with MBDB.get_session() as session:
            # Upsert groups
            for group_dto in DEFAULT_GROUPS:
                session.execute(
                    insert(cls)
                    .values(
                        name=group_dto.name,
                        description=group_dto.description,
                        is_system=group_dto.is_system,
                    )
                    .on_conflict_do_nothing(index_elements=["name"])
                )

            # Collect and upsert all users
            all_users = {
                user_dto.user_name: user_dto
                for group_dto in DEFAULT_GROUPS
                for user_dto in (group_dto.members or [])
            }
            for user_dto in all_users.values():
                session.execute(
                    insert(Users)
                    .values(name=user_dto.user_name, email=user_dto.email)
                    .on_conflict_do_nothing(index_elements=["name"])
                )

            session.flush()

            # Cache lookups
            groups = {g.name: g for g in session.execute(select(cls)).scalars()}
            users = {u.name: u for u in session.execute(select(Users)).scalars()}

            # Upsert user-group memberships
            for group_dto in DEFAULT_GROUPS:
                group_obj = groups[group_dto.name]
                for user_dto in group_dto.members or []:
                    user_obj = users[user_dto.user_name]
                    session.execute(
                        insert(UserGroups)
                        .values(user_id=user_obj.user_id, group_id=group_obj.group_id)
                        .on_conflict_do_nothing()
                    )

            # Upsert permissions
            for grant, resource_type, resource_name in DEFAULT_PERMISSIONS:
                collection_id = None
                if resource_name and resource_type == BackendResourceType.COLLECTION:
                    collection = session.scalars(
                        select(Collections).where(Collections.name == resource_name)
                    ).one()
                    if not collection:
                        raise MatchboxCollectionNotFoundError(name=resource_name)
                    collection_id = collection.collection_id

                session.execute(
                    insert(Permissions)
                    .values(
                        group_id=groups[grant.group_name].group_id,
                        permission=grant.permission,
                        is_system=True
                        if resource_type == BackendResourceType.SYSTEM
                        else None,
                        collection_id=collection_id,
                    )
                    .on_conflict_do_nothing(constraint="unique_permission_grant")
                )

            session.commit()


class Permissions(CountMixin, MBDB.MatchboxBase):
    """Permissions granted to groups on resources.

    Each resource type should have one column. This creates lots of nulls,
    which are cheap in PostgreSQL and are on an ultimately small table,
    and avoids a polymorphic association.
    """

    __tablename__ = "permissions"

    # Columns
    permission_id: Mapped[int] = mapped_column(
        BIGINT, primary_key=True, autoincrement=True
    )
    permission: Mapped[str] = mapped_column(TEXT, nullable=False)
    group_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("groups.group_id", ondelete="CASCADE"),
        nullable=False,
    )
    collection_id: Mapped[int | None] = mapped_column(
        BIGINT,
        ForeignKey("collections.collection_id", ondelete="CASCADE"),
        nullable=True,
    )
    is_system: Mapped[bool | None] = mapped_column(
        BOOLEAN,
        nullable=True,
    )

    # Relationships
    group: Mapped["Groups"] = relationship(back_populates="permissions")
    collection: Mapped["Collections | None"] = relationship(
        back_populates="permissions"
    )

    # Constraints and indices
    __table_args__ = (
        CheckConstraint(
            "permission IN ('read', 'write', 'admin')",
            name="valid_permission",
        ),
        CheckConstraint(
            "(collection_id IS NOT NULL AND is_system IS NULL) OR "
            "(collection_id IS NULL AND is_system = true)",
            name="exactly_one_resource",
        ),
        UniqueConstraint(
            "permission",
            "group_id",
            "collection_id",
            "is_system",
            name="unique_permission_grant",
            postgresql_nulls_not_distinct=True,
        ),
    )


class EvalJudgements(CountMixin, MBDB.MatchboxBase):
    """Table of evaluation judgements produced by human validators."""

    __tablename__ = "eval_judgements"

    # Columns
    judgement_id: Mapped[int] = mapped_column(BIGINT, primary_key=True)
    user_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False
    )
    endorsed_cluster_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), nullable=False
    )
    shown_cluster_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), nullable=False
    )
    tag: Mapped[str] = mapped_column(TEXT, nullable=True)
    timestamp: Mapped[DateTime] = mapped_column(DateTime(timezone=True), nullable=False)

    # Relationships
    user: Mapped["Users"] = relationship(back_populates="judgements")


class ModelEdges(CountMixin, MBDB.MatchboxBase):
    """Table of results for a model step.

    Stores the raw left/right scores created by a model.
    """

    __tablename__ = "model_edges"

    # Columns
    result_id: Mapped[int] = mapped_column(BIGINT, primary_key=True, autoincrement=True)
    step_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        nullable=False,
    )
    left_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), nullable=False
    )
    right_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), nullable=False
    )
    score: Mapped[float] = mapped_column(REAL, nullable=False)

    # Relationships
    proposed_by: Mapped["Steps"] = relationship(back_populates="model_edges")

    # Constraints
    __table_args__ = (
        Index("ix_model_edges_step", "step_id"),
        CheckConstraint(
            "score >= 0.0 AND score <= 1.0",
            name="valid_score",
        ),
        UniqueConstraint("step_id", "left_id", "right_id"),
    )


class ResolverClusters(CountMixin, MBDB.MatchboxBase):
    """Association table linking resolver steps to cluster IDs."""

    __tablename__ = "resolver_clusters"

    step_id: Mapped[int] = mapped_column(
        BIGINT,
        ForeignKey("steps.step_id", ondelete="CASCADE"),
        primary_key=True,
    )
    cluster_id: Mapped[int] = mapped_column(
        BIGINT, ForeignKey("clusters.cluster_id", ondelete="CASCADE"), primary_key=True
    )

    proposed_by: Mapped["Steps"] = relationship(back_populates="resolver_clusters")

    __table_args__ = (Index("ix_resolver_clusters_step", "step_id"),)
