from datetime import datetime, timezone

from sqlalchemy import (
    BigInteger, Boolean, Column, DateTime, ForeignKey, Index, Integer, Table,
    Text, UniqueConstraint, text,
)
from sqlalchemy.orm import Mapped, mapped_column

from scribe.models import Base
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso


class Rulebook(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "rulebooks"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    owner_user_id: Mapped[int] = mapped_column(
        BigInteger, ForeignKey("users.id", ondelete="CASCADE")
    )
    title: Mapped[str] = mapped_column(Text)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    always_on: Mapped[bool] = mapped_column(
        Boolean, default=False, nullable=False, server_default="false"
    )

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "owner_user_id": self.owner_user_id,
            "title": self.title,
            "description": self.description or "",
            "always_on": self.always_on,
            "created_at": iso(self.created_at),
            "updated_at": iso(self.updated_at),
        }


class RulebookTopic(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "rulebook_topics"
    # Partial unique: a title is unique among LIVE topics in a rulebook, so a
    # trashed topic doesn't block recreating/restoring the same title.
    __table_args__ = (
        Index(
            "uq_topic_per_rulebook", "rulebook_id", "title",
            unique=True, postgresql_where=text("deleted_at IS NULL"),
        ),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    rulebook_id: Mapped[int] = mapped_column(
        BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE")
    )
    title: Mapped[str] = mapped_column(Text)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    order_index: Mapped[int] = mapped_column(Integer, default=0)

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "rulebook_id": self.rulebook_id,
            "title": self.title,
            "description": self.description or "",
            "order_index": self.order_index,
            "created_at": iso(self.created_at),
            "updated_at": iso(self.updated_at),
        }


class Rule(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "rules"
    # Partial unique: title unique among LIVE rules in a topic (soft-deleted
    # rules don't block recreating/restoring the same title).
    __table_args__ = (
        Index(
            "uq_rule_per_topic", "topic_id", "title",
            unique=True, postgresql_where=text("deleted_at IS NULL"),
        ),
    )

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    # Exactly one of topic_id / project_id is set — enforced by CHECK
    # constraint ck_rule_topic_xor_project (migration 0059).
    topic_id: Mapped[int | None] = mapped_column(
        BigInteger,
        ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
        nullable=True,
    )
    project_id: Mapped[int | None] = mapped_column(
        BigInteger,
        ForeignKey("projects.id", ondelete="CASCADE"),
        nullable=True,
        index=True,
    )
    title: Mapped[str] = mapped_column(Text)
    statement: Mapped[str] = mapped_column(Text)
    # WHEN this rule applies — the trigger, not the instruction. Required of
    # new rules at the service layer and nullable here, because rules written
    # before migration 0088 have none and a migration cannot invent one.
    # It carries three jobs at once (note 3026): it is the tier test made
    # concrete, the readable form of the canon tag, and the half of the
    # document that makes a rule findable by meaning.
    when_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
    # always_on = preloaded into every session, as every rule is today.
    # conditional = reachable, and surfaced when its trigger fires. The
    # default preserves existing behaviour exactly: nothing stops binding
    # because of an upgrade. CHECK ck_rules_tier (migration 0088, rule 36).
    tier: Mapped[str] = mapped_column(Text, default="always_on", server_default="always_on")
    why: Mapped[str | None] = mapped_column(Text, nullable=True)
    how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
    # The three fields that tell a CONSTRAINT apart from a NORM (milestone
    # 312). A norm is a decision — no truth value, changes only when its
    # author changes it. A constraint asserts a fact about someone else's
    # software, and goes false with nobody watching: every stale rule the
    # 307 audit found was one, and no norm had rotted.
    #
    # `verify_with` is how to check the rule is still true; `expires_when` is
    # the STATE that ends it, deliberately not a date — constraints expire
    # when the ground moves, not on a schedule. `verified_at` NULL means
    # never checked, and sorts FIRST in the sweep: unexamined outranks
    # examined-long-ago.
    #
    # Most rules should leave all three empty. A null `verify_with` is not a
    # gap — it is the marker for "this is a decision, there is nothing to go
    # and check," and the signal is only worth reading while that stays true.
    verify_with: Mapped[str | None] = mapped_column(Text, nullable=True)
    expires_when: Mapped[str | None] = mapped_column(Text, nullable=True)
    verified_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    # The record that caused this rule — the edge notes and tasks already
    # have. Rule 46's `why` names note 2813 in prose; this is that link as a
    # field, so it survives a rewording of the paragraph.
    arose_from_id: Mapped[int | None] = mapped_column(
        BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
    )
    order_index: Mapped[int] = mapped_column(Integer, default=0)

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "topic_id": self.topic_id,
            "project_id": self.project_id,
            "title": self.title,
            "statement": self.statement,
            "when_to_apply": self.when_to_apply or "",
            "tier": self.tier,
            "why": self.why or "",
            "how_to_apply": self.how_to_apply or "",
            "verify_with": self.verify_with or "",
            "expires_when": self.expires_when or "",
            "verified_at": iso(self.verified_at),
            "arose_from_id": self.arose_from_id,
            "order_index": self.order_index,
            "created_at": iso(self.created_at),
            "updated_at": iso(self.updated_at),
        }


# Which global AREA a rule is about (milestone 307). Points at the canonical
# catalog, NEVER at a project's `systems` row: a rule that spans projects
# cannot be chained to one project's vocabulary. This is the edge three
# projects were drawing by hand, as rule text copied into a System's charter.
rule_systems = Table(
    "rule_systems",
    Base.metadata,
    Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
    Column("canonical_id", Integer, ForeignKey("canonical_systems.id", ondelete="CASCADE"), primary_key=True),
    Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)


class RuleRelation(Base, CreatedAtMixin):
    """A typed edge between two rules. Each kind exists because its ABSENCE
    forced a workaround somewhere in the operator's rulebook (note 3026).

    - ``co_surfaces`` — these fail together, so they must arrive together.
      Without it, the only way to guarantee that was to merge them into one
      row, which is what happened to rule 46: split into 144, folded back the
      same day because "either rule could surface without the other."
      Symmetric in meaning; stored once and read both ways.
    - ``overrides`` — this rule supersedes that one for its scope. Only
      *suppression* existed, so an override had to be written as a parallel
      rule that then drifts from its parent.
    - ``elaborates`` — this rule adds local specifics to that one; surfacing
      the parent brings the addendum with it.

    ``note`` records WHY the edge was drawn, for the same reason a rule
    carries `why`: a later reader deciding whether it still holds needs the
    reasoning, not just the fact.
    """

    __tablename__ = "rule_relations"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    from_rule_id: Mapped[int] = mapped_column(
        BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
    )
    to_rule_id: Mapped[int] = mapped_column(
        BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), index=True
    )
    # CHECK ck_rule_relations_kind (migration 0088, rule 36).
    kind: Mapped[str] = mapped_column(Text)
    note: Mapped[str | None] = mapped_column(Text, nullable=True)

    __table_args__ = (
        UniqueConstraint("from_rule_id", "to_rule_id", "kind", name="uq_rule_relations_edge"),
    )

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "from_rule_id": self.from_rule_id,
            "to_rule_id": self.to_rule_id,
            "kind": self.kind,
            "note": self.note or "",
            "created_at": iso(self.created_at),
        }


# Pure many-to-many — no model class, just the join table.
project_rulebook_subscriptions = Table(
    "project_rulebook_subscriptions",
    Base.metadata,
    Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
    Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
    Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)

# Suppressions — let a project mute individual rules or whole topics from
# rulebooks it subscribes to, without unsubscribing the rulebook itself.
# FKs CASCADE so the row vanishes when its parent is removed.
project_rule_suppressions = Table(
    "project_rule_suppressions",
    Base.metadata,
    Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
    Column("rule_id", BigInteger, ForeignKey("rules.id", ondelete="CASCADE"), primary_key=True),
    Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)

# A project's opt-out of a whole ALWAYS-ON rulebook (milestone 297): the
# sibling of the two suppression tables below, one level up. Always-on
# rulebooks bind every project implicitly; an inception decision can exclude
# specific ones for this project, and get_applicable_rules /
# list_always_on_rules(project_id) skip them. FKs CASCADE like the others.
project_rulebook_exclusions = Table(
    "project_rulebook_exclusions",
    Base.metadata,
    Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
    Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
    Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)

project_topic_suppressions = Table(
    "project_topic_suppressions",
    Base.metadata,
    Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
    Column("topic_id", BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE"), primary_key=True),
    Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
)
