import enum
from datetime import date, datetime

from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column

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


class TaskStatus(str, enum.Enum):
    todo = "todo"
    in_progress = "in_progress"
    done = "done"
    cancelled = "cancelled"


class TaskPriority(str, enum.Enum):
    none = "none"
    low = "low"
    medium = "medium"
    high = "high"


class TaskKind(str, enum.Enum):
    """What KIND of work a task is. Mirrors CHECK notes_task_kind_check.

    Every value the COLUMN may hold, including `plan`. That is deliberate:
    plans became milestones in 0066, but historical plan-tasks still carry
    the value and must stay readable and writable. Refusing to MINT a new
    plan is a door policy (see the create/update task tools), not a
    statement about what the column accepts — conflating the two would make
    old rows unwritable, which is how a retired value turns into corrupt
    data.
    """

    work = "work"
    issue = "issue"
    spike = "spike"
    plan = "plan"


class Note(Base, TimestampMixin, SoftDeleteMixin):
    __tablename__ = "notes"

    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
    )
    title: Mapped[str] = mapped_column(Text, default="")
    body: Mapped[str] = mapped_column(Text, default="")
    description: Mapped[str | None] = mapped_column(Text, nullable=True)
    tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list)
    parent_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
    )
    # Provenance: the task/feature an issue arose from. Distinct from parent_id
    # (sub-task hierarchy) — this is "what spawned this". Only meaningful for
    # issues; nullable for every record.
    arose_from_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
    )
    project_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
    )
    milestone_id: Mapped[int | None] = mapped_column(
        Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True
    )
    status: Mapped[str | None] = mapped_column(Text, nullable=True)
    priority: Mapped[str | None] = mapped_column(Text, nullable=True)
    due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
    started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
    recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
    recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True
    )
    # Note type — 'note' (default) or 'process' (a stored process). Task-ness is
    # tracked by `status`, not here. (person/place/list entity types removed 2026-07.)
    note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note")
    # Task sub-kind — what KIND of work this is, not how it is going:
    #   work   (default) — ships a change
    #   issue  — corrective; something was broken (0065)
    #   spike  — time-boxed, and its output is KNOWLEDGE rather than a change;
    #            it succeeds by producing an answer, and nothing ships (0091)
    #   plan   — retired since 0066 (plans are milestones), kept in the CHECK
    #            so historical plan-tasks stay writable
    # Only meaningful when the note is a task (status is not None); ordinary
    # notes keep the 'work' default and ignore it. Orthogonal to note_type
    # (which is the note/entity axis). CHECK notes_task_kind_check (rule 36).
    task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
    # Queryable structured fields for typed records — currently snippets, whose
    # name/language/signature/locations live here so they can be INDEXED. The
    # body keeps the same facts in readable markdown and remains what gets
    # embedded; this is a mirror for querying, not the source of truth for
    # display — and it is DERIVED, so every path that writes a snippet's body
    # rewrites it too (services/snippets.recompose_data, called from
    # notes.update_note). 0070 left it NULL on existing rows and
    # snippets.backfill_snippet_data filled them at startup; readers still fall
    # back to parsing the body when it is absent (snippet_fields).
    data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
    # The three fields that tell a CONSTRAINT apart from a NORM (milestone
    # 317, migration 0092) — the same trio `rules` carries, and for the same
    # reason. 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. Only constraints get a
    # check.
    #
    # `verify_with` is how to check it 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.
    #
    # These sit on `notes`, so every kind of row in this table has them, but
    # only non-task, non-snippet records are OFFERED them (gated in
    # services/notes.py). A task's decay is its status — a done issue records
    # what happened and cannot go false — and a snippet already carries a
    # richer, location-aware verdict in `data.verification`. On those rows
    # these stay null, which is also what they mean.
    #
    # Most notes 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 sweep is only worth reading while that holds.
    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
    )

    __table_args__ = (
        Index("ix_notes_tags", "tags", postgresql_using="gin"),
        Index("ix_notes_status", "status"),
        Index("ix_notes_title", "title"),
        Index("ix_notes_user_id", "user_id"),
        Index("ix_notes_project_id", "project_id"),
        Index("ix_notes_milestone_id", "milestone_id"),
        Index("ix_notes_note_type", "note_type"),
        Index("ix_notes_arose_from_id", "arose_from_id"),
        # Containment queries into `data` — e.g. which snippets name a given
        # repo/path in their locations. See migration 0070.
        Index("ix_notes_data_gin", "data", postgresql_using="gin"),
    )

    @property
    def is_task(self) -> bool:
        return self.status is not None

    def to_dict(self) -> dict:
        return {
            "id": self.id,
            "title": self.title,
            "body": self.body,
            "description": self.description,
            "tags": self.tags or [],
            "parent_id": self.parent_id,
            "arose_from_id": self.arose_from_id,
            "project_id": self.project_id,
            "milestone_id": self.milestone_id,
            "status": self.status,
            "priority": self.priority,
            "due_date": iso(self.due_date),
            "started_at": iso(self.started_at),
            "completed_at": iso(self.completed_at),
            "recurrence_rule": self.recurrence_rule,
            "recurrence_next_spawn_at": iso(self.recurrence_next_spawn_at),
            "is_task": self.is_task,
            "note_type": self.note_type or "note",
            "task_kind": self.task_kind,
            # Serialized unconditionally, like every other field a given row
            # kind may not use (recurrence, started_at, the task fields). The
            # DERIVED "last_verified" label is the one that appears only when
            # a check exists — a raw projection of the row should not make a
            # client branch on which keys are present.
            "verify_with": self.verify_with or "",
            "expires_when": self.expires_when or "",
            "verified_at": iso(self.verified_at),
            "created_at": iso(self.created_at),
            "updated_at": iso(self.updated_at),
        }
