"""Database service - asyncpg connection pool and queries."""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import re
from datetime import date, datetime
from uuid import UUID, uuid4

import asyncpg
from pgvector.asyncpg import register_vector

from legal_mcp import config
from legal_mcp.services import court_citation, halacha_quality, principles

logger = logging.getLogger(__name__)

# ── Primary-document classification (WS2 / task #200) ────────────────
# Canonical, single-source list of "primary" (מסמך עיקרי) doc_types — the
# substantive case documents the chair tracks for analysis (appeal, the
# replies/objections, the hearing protocol, the appraisal, and the
# committee decision). Every OTHER doc_type (plan, permit, court_decision,
# exhibit, reference…) is secondary. Chair-approved set (plan WS2 §1).
#
# G1/G2/INV-DM7: `is_primary` is DERIVED from `doc_type`, never an
# independently-written column. There is ONE source of truth: this tuple.
# The DB column `documents.is_primary` is a GENERATED ALWAYS … STORED column
# (V47) computed by Postgres from `doc_type`, so it can never drift; the
# Python helper below mirrors the same list for read-time derivation and for
# building the generated-column expression. No parallel write path.
PRIMARY_DOC_TYPES: tuple[str, ...] = (
    "appeal",      # כתב-ערר
    "response",    # תשובה / תגובה
    "objection",   # התנגדות
    "protocol",    # פרוטוקול-דיון
    "appraisal",   # שומה
    "decision",    # החלטת-ועדה
)


def is_primary_doc_type(doc_type: str | None) -> bool:
    """Whether a doc_type is a 'primary' (מסמך עיקרי) document. Single source
    of truth = PRIMARY_DOC_TYPES; mirrors the V47 generated column."""
    return doc_type in PRIMARY_DOC_TYPES


def _primary_doc_types_sql_array() -> str:
    """SQL ARRAY[...] literal of PRIMARY_DOC_TYPES for the generated column.
    Values are fixed identifiers from this module (no user input) — safe to
    inline; kept derived from the tuple so the list has ONE definition."""
    quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in PRIMARY_DOC_TYPES)
    return f"ARRAY[{quoted}]::text[]"


_pool: asyncpg.Pool | None = None
_schema_ready: bool = False
_init_lock: asyncio.Lock = asyncio.Lock()


async def get_pool() -> asyncpg.Pool:
    """Return the connection pool, creating it (and running schema init) lazily.

    The MCP server's `lifespan` no longer blocks on schema init — it's done
    here on first DB access. This keeps the `initialize`/`tools/list` MCP
    handshake immediate so Claude Code never sees a stale "No such tool".
    """
    global _pool, _schema_ready
    if _pool is not None and _schema_ready:
        return _pool

    async with _init_lock:
        if _pool is None:
            # First, ensure pgvector extension exists (before registering type codec)
            conn = await asyncpg.connect(config.POSTGRES_URL)
            try:
                await conn.execute('CREATE EXTENSION IF NOT EXISTS vector')
                await conn.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
            finally:
                await conn.close()

            _pool = await asyncpg.create_pool(
                config.POSTGRES_URL,
                min_size=2,
                max_size=10,
                init=_init_connection,
            )

        if not _schema_ready:
            await _run_schema_migrations(_pool)
            _schema_ready = True

    return _pool


async def _init_connection(conn: asyncpg.Connection) -> None:
    await register_vector(conn)


async def close_pool() -> None:
    global _pool
    if _pool:
        await _pool.close()
        _pool = None


# ── Schema ──────────────────────────────────────────────────────────

SCHEMA_SQL = """

CREATE TABLE IF NOT EXISTS cases (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_number TEXT UNIQUE NOT NULL,
    title TEXT NOT NULL,
    appellants JSONB DEFAULT '[]',
    respondents JSONB DEFAULT '[]',
    subject TEXT DEFAULT '',
    property_address TEXT DEFAULT '',
    permit_number TEXT DEFAULT '',
    committee_type TEXT DEFAULT 'ועדה מקומית',
    status TEXT DEFAULT 'new',
    hearing_date DATE,
    decision_date DATE,
    tags JSONB DEFAULT '[]',
    notes TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE IF NOT EXISTS documents (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    doc_type TEXT NOT NULL,
    title TEXT NOT NULL,
    file_path TEXT NOT NULL,
    extracted_text TEXT DEFAULT '',
    extraction_status TEXT DEFAULT 'pending',
    page_count INTEGER,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT now()
);

-- INV-TOOL3 / GAP-52: SHA-256 of the uploaded file bytes, for idempotent upload
-- (re-uploading the same file to a case returns the existing document). Empty
-- default = legacy rows with unknown hash; never matched as a duplicate.
ALTER TABLE documents ADD COLUMN IF NOT EXISTS content_hash text NOT NULL DEFAULT '';

CREATE TABLE IF NOT EXISTS document_chunks (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
    case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    section_type TEXT DEFAULT 'other',
    embedding vector(1024),
    page_number INTEGER,
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE IF NOT EXISTS style_corpus (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
    decision_number TEXT,
    decision_date DATE,
    subject_categories JSONB DEFAULT '[]',
    full_text TEXT NOT NULL,
    summary TEXT DEFAULT '',
    outcome TEXT DEFAULT '',
    key_principles JSONB DEFAULT '[]',
    practice_area TEXT DEFAULT 'appeals_committee',
    appeal_subtype TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE IF NOT EXISTS style_patterns (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    pattern_type TEXT NOT NULL,
    pattern_text TEXT NOT NULL,
    frequency INTEGER DEFAULT 1,
    context TEXT DEFAULT '',
    examples JSONB DEFAULT '[]',
    appeal_subtype TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_chunks_embedding
    ON document_chunks USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);

CREATE INDEX IF NOT EXISTS idx_chunks_case ON document_chunks(case_id);
CREATE INDEX IF NOT EXISTS idx_chunks_doc ON document_chunks(document_id);
CREATE INDEX IF NOT EXISTS idx_docs_case ON documents(case_id);
CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status);
CREATE INDEX IF NOT EXISTS idx_cases_number ON cases(case_number);
"""


MIGRATIONS_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS expected_outcome TEXT DEFAULT '';

CREATE TABLE IF NOT EXISTS audit_log (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    action TEXT NOT NULL,
    case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
    document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
    details JSONB DEFAULT '{}',
    actor TEXT DEFAULT 'system',
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_case ON audit_log(case_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
"""

# ── Phase 3: Workflow expansion ────────────────────────────────────

SCHEMA_V3_SQL = """

-- הרחבת decisions עם שדות חדשים
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS direction_doc JSONB DEFAULT NULL;
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS outcome_reasoning TEXT DEFAULT '';

-- הרחבת cases עם appeal_type (אם לא קיים)
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT '';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
-- active_draft_path = path to the DOCX that is the current source of truth
-- for this case's decision text. Set to the latest טיוטה-v*.docx after export,
-- or the latest עריכה-v*.docx after user upload. Used by revise_draft to know
-- what file to base Track Changes revisions on.
ALTER TABLE cases ADD COLUMN IF NOT EXISTS active_draft_path TEXT;

-- הרחבת style_corpus עם practice_area / appeal_subtype
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT 'appeals_committee';
ALTER TABLE style_corpus ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';

-- הרחבת style_patterns עם appeal_subtype לניתוח סגנון נפרד לכל סוג ערר
ALTER TABLE style_patterns ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';

-- decision_lessons: per-decision learnings the chair / curator / style_analyzer
-- attaches to a corpus row. The generic legal-decision-lessons.md file stays
-- as the source of truth for cross-corpus patterns; this table stores the
-- granular "what we learned from THIS decision" notes that drive the writer's
-- future drafts and let the curator look up prior observations on the same row.
CREATE TABLE IF NOT EXISTS decision_lessons (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    style_corpus_id UUID NOT NULL REFERENCES style_corpus(id) ON DELETE CASCADE,
    lesson_text TEXT NOT NULL,
    category TEXT DEFAULT 'general',           -- style / structure / lexicon / tabular / general
    source TEXT DEFAULT 'manual',              -- manual / curator / chair / style_analyzer
    applied_to_skill BOOLEAN DEFAULT false,    -- DEPRECATED (LRN-1/INV-IA3, #131): informative-only flag, no longer read/written; review_status is the single writer gate. Column kept for back-compat (no migration).
    created_by TEXT DEFAULT 'chaim',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_decision_lessons_corpus ON decision_lessons(style_corpus_id);
CREATE INDEX IF NOT EXISTS idx_decision_lessons_applied ON decision_lessons(applied_to_skill);

-- chat_conversations / chat_messages: persistent history for the
-- "שיחה עם הסוכן" tab on /training. Each conversation can optionally be
-- scoped to a single style_corpus row (when the chair starts a chat
-- "about decision X"). claude_session_id is the value the local claude
-- CLI returns in stream-json — we pass it back via `--resume` on the
-- next message so the model continues the same conversation without
-- re-loading the system prompt every time.
CREATE TABLE IF NOT EXISTS chat_conversations (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    title TEXT NOT NULL DEFAULT 'שיחה חדשה',
    style_corpus_id UUID REFERENCES style_corpus(id) ON DELETE SET NULL,
    claude_session_id TEXT,
    system_prompt_version TEXT DEFAULT 'v1',
    created_at TIMESTAMPTZ DEFAULT now(),
    last_message_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE IF NOT EXISTS chat_messages (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    conversation_id UUID NOT NULL REFERENCES chat_conversations(id) ON DELETE CASCADE,
    role TEXT NOT NULL,                -- 'user' | 'assistant'
    content TEXT NOT NULL,
    raw_events JSONB DEFAULT '[]',     -- stream-json events for the assistant turn (optional, for debug)
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_chat_messages_conv ON chat_messages(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_chat_conv_corpus ON chat_conversations(style_corpus_id);
CREATE INDEX IF NOT EXISTS idx_chat_conv_last ON chat_conversations(last_message_at DESC);

-- טבלת qa_results
CREATE TABLE IF NOT EXISTS qa_results (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
    case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    check_name TEXT NOT NULL,
    passed BOOLEAN NOT NULL,
    severity TEXT DEFAULT 'warning',
    errors JSONB DEFAULT '[]',
    details TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_qa_results_decision ON qa_results(decision_id);
CREATE INDEX IF NOT EXISTS idx_qa_results_case ON qa_results(case_id);

-- טבלת decision_definitions (אם לא קיימת)
CREATE TABLE IF NOT EXISTS decision_definitions (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
    term TEXT NOT NULL,
    definition TEXT NOT NULL,
    block_id TEXT DEFAULT 'block-he',
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_definitions_decision ON decision_definitions(decision_id);

-- טבלת appeal_type_rules (אם לא קיימת)
CREATE TABLE IF NOT EXISTS appeal_type_rules (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    appeal_type TEXT NOT NULL,
    rule_category TEXT NOT NULL,
    rule_key TEXT NOT NULL,
    rule_value JSONB NOT NULL,
    description TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(appeal_type, rule_category, rule_key)
);

-- image_placeholders על decision_blocks
ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]';
"""

# ── Phase 2: Decision + Knowledge + RAG layers ────────────────────

SCHEMA_V2_SQL = """

-- ═══════════════════════════════════════════════════════════════════
-- Layer 2: Decision
-- ═══════════════════════════════════════════════════════════════════

-- decisions: מטאדטה של החלטה (גרסה אחת = רשומה אחת)
CREATE TABLE IF NOT EXISTS decisions (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    version INTEGER DEFAULT 1,
    status TEXT DEFAULT 'draft',               -- draft/review/final/published
    outcome TEXT DEFAULT '',                   -- rejected/accepted/partial
    outcome_summary TEXT DEFAULT '',           -- תמצית תוצאה (שורה אחת)
    total_paragraphs INTEGER DEFAULT 0,
    total_words INTEGER DEFAULT 0,
    decision_date DATE,
    author TEXT DEFAULT 'דפנה תמיר',
    panel_members JSONB DEFAULT '[]',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(case_id, version)
);

-- decision_blocks: 12 בלוקים לפי block-schema.md
CREATE TABLE IF NOT EXISTS decision_blocks (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
    block_id TEXT NOT NULL,                    -- block-alef, block-bet, ... block-yod-bet
    block_index INTEGER NOT NULL,              -- 1-12
    title TEXT DEFAULT '',                     -- כותרת הבלוק (ריק לבלוקים ללא כותרת)
    content TEXT DEFAULT '',                   -- תוכן מלא (markdown)
    word_count INTEGER DEFAULT 0,
    weight_percent NUMERIC(5,2) DEFAULT 0,     -- משקל בפועל (%)
    generation_type TEXT DEFAULT '',            -- template-fill/reproduction/paraphrase/...
    model_used TEXT DEFAULT '',                 -- sonnet/opus/script
    temperature NUMERIC(3,2) DEFAULT 0,
    status TEXT DEFAULT 'empty',               -- empty/draft/review/final
    notes TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(decision_id, block_id)
);

-- decision_paragraphs: סעיפים בודדים עם מעקב ציטוטים
CREATE TABLE IF NOT EXISTS decision_paragraphs (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    block_id UUID REFERENCES decision_blocks(id) ON DELETE CASCADE,
    paragraph_number INTEGER NOT NULL,         -- מספור רציף בתוך ההחלטה
    content TEXT NOT NULL,
    word_count INTEGER DEFAULT 0,
    citations JSONB DEFAULT '[]',              -- [{case_law_id, text, type}]
    cross_references JSONB DEFAULT '[]',       -- הפניות לסעיפים אחרים ["סעיף 5 לעיל"]
    created_at TIMESTAMPTZ DEFAULT now()
);

-- claims: טענות צדדים (בלוק ז)
CREATE TABLE IF NOT EXISTS claims (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    party_role TEXT NOT NULL,                  -- appellant/respondent/permit_applicant/committee
    party_name TEXT DEFAULT '',
    claim_text TEXT NOT NULL,
    claim_index INTEGER DEFAULT 0,             -- סדר הופעה
    source_document TEXT DEFAULT '',            -- מאיזה מסמך חולצה הטענה
    addressed_in_paragraph INTEGER,            -- באיזה סעיף בדיון נענתה
    created_at TIMESTAMPTZ DEFAULT now()
);

-- ═══════════════════════════════════════════════════════════════════
-- Layer 3: Legal Knowledge
-- ═══════════════════════════════════════════════════════════════════

-- case_law: פסיקה (תקדימים)
CREATE TABLE IF NOT EXISTS case_law (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_number TEXT UNIQUE NOT NULL,          -- עע"מ 3975/22 או ערר 1011-03-25
    case_name TEXT NOT NULL,                   -- שם קצר: "ב. קרן-נכסים"
    court TEXT DEFAULT '',                     -- בג"ץ / עליון / מנהלי / ועדת ערר
    date DATE,
    subject_tags JSONB DEFAULT '[]',           -- ["proprietary_claims", "parking"]
    summary TEXT DEFAULT '',                   -- תמצית 2-3 משפטים
    key_quote TEXT DEFAULT '',                 -- ציטוט מרכזי
    full_text TEXT DEFAULT '',                 -- טקסט מלא אם זמין
    source_url TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

-- case_law_citations: קשרים בין פסיקה להחלטות שלנו
CREATE TABLE IF NOT EXISTS case_law_citations (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE,
    paragraph_id UUID REFERENCES decision_paragraphs(id) ON DELETE SET NULL,
    citation_type TEXT DEFAULT 'support',      -- support/distinguish/overrule/obiter
    context_text TEXT DEFAULT '',              -- ההקשר שבו צוטט
    created_at TIMESTAMPTZ DEFAULT now()
);

-- statutory_provisions: חקיקה נפוצה
CREATE TABLE IF NOT EXISTS statutory_provisions (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    statute_name TEXT NOT NULL,                -- "חוק התכנון והבנייה"
    section_number TEXT NOT NULL,              -- "152(א)(2)"
    section_title TEXT DEFAULT '',             -- "זכות ערר"
    full_text TEXT DEFAULT '',                 -- נוסח הסעיף
    common_usage TEXT DEFAULT '',              -- מתי משתמשים
    subject_tags JSONB DEFAULT '[]',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(statute_name, section_number)
);

-- transition_phrases: ביטויי מעבר של דפנה
CREATE TABLE IF NOT EXISTS transition_phrases (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    phrase TEXT UNIQUE NOT NULL,               -- "ועל מנת לא לצאת בחסר"
    usage_context TEXT DEFAULT '',             -- מתי להשתמש
    block_types JSONB DEFAULT '[]',            -- באילו בלוקים: ["block-yod"]
    frequency INTEGER DEFAULT 1,              -- כמה פעמים ראינו
    source_decision TEXT DEFAULT '',           -- מאיזו החלטה
    created_at TIMESTAMPTZ DEFAULT now()
);

-- lessons_learned: לקחים מהשוואת טיוטות לגרסאות סופיות
CREATE TABLE IF NOT EXISTS lessons_learned (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    lesson_title TEXT NOT NULL,                -- "Discussion = continuous essay, no sub-headers"
    lesson_text TEXT NOT NULL,                 -- תיאור מלא
    category TEXT DEFAULT '',                  -- structure/style/content/process
    applies_to JSONB DEFAULT '[]',             -- ["block-yod", "all"]
    source_case TEXT DEFAULT '',               -- "הכט 1180-1181"
    severity TEXT DEFAULT 'important',         -- critical/important/nice-to-have
    created_at TIMESTAMPTZ DEFAULT now()
);

-- ═══════════════════════════════════════════════════════════════════
-- Layer 4: Extended RAG
-- ═══════════════════════════════════════════════════════════════════

-- paragraph_embeddings: embeddings של סעיפים בהחלטות
CREATE TABLE IF NOT EXISTS paragraph_embeddings (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    paragraph_id UUID REFERENCES decision_paragraphs(id) ON DELETE CASCADE,
    embedding vector(1024),
    created_at TIMESTAMPTZ DEFAULT now()
);

-- case_law_embeddings: embeddings של פסיקה
CREATE TABLE IF NOT EXISTS case_law_embeddings (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    chunk_text TEXT NOT NULL,
    embedding vector(1024),
    created_at TIMESTAMPTZ DEFAULT now()
);

-- ═══════════════════════════════════════════════════════════════════
-- Chair Feedback (הערות דפנה על טיוטות)
-- ═══════════════════════════════════════════════════════════════════

CREATE TABLE IF NOT EXISTS chair_feedback (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
    block_id TEXT DEFAULT '',                    -- block-yod, block-vav, etc.
    feedback_text TEXT NOT NULL,                 -- ההערה של דפנה
    category TEXT DEFAULT 'other',              -- missing_content/wrong_tone/wrong_structure/factual_error/style/other
    lesson_extracted TEXT DEFAULT '',            -- הלקח שהופק
    applied_to TEXT[] DEFAULT '{}',             -- לאילו קבצים/כללים הלקח יושם
    resolved BOOLEAN DEFAULT FALSE,             -- האם הלקח יושם
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE IF NOT EXISTS tag_company_mappings (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    tag TEXT NOT NULL,                            -- appeal_subtype value (e.g. building_permit)
    tag_label TEXT NOT NULL DEFAULT '',            -- Hebrew display label
    company_id TEXT NOT NULL,                     -- Paperclip company UUID
    company_name TEXT NOT NULL DEFAULT '',         -- cached company name for display
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(tag, company_id)
);

-- ═══════════════════════════════════════════════════════════════════
-- Indexes
-- ═══════════════════════════════════════════════════════════════════

CREATE INDEX IF NOT EXISTS idx_decisions_case ON decisions(case_id);
CREATE INDEX IF NOT EXISTS idx_decisions_status ON decisions(status);
CREATE INDEX IF NOT EXISTS idx_decision_blocks_decision ON decision_blocks(decision_id);
CREATE INDEX IF NOT EXISTS idx_decision_blocks_block_id ON decision_blocks(block_id);
CREATE INDEX IF NOT EXISTS idx_decision_paragraphs_block ON decision_paragraphs(block_id);
CREATE INDEX IF NOT EXISTS idx_claims_case ON claims(case_id);
CREATE INDEX IF NOT EXISTS idx_claims_role ON claims(party_role);
CREATE INDEX IF NOT EXISTS idx_case_law_subject ON case_law USING gin(subject_tags);
CREATE INDEX IF NOT EXISTS idx_case_law_citations_decision ON case_law_citations(decision_id);
CREATE INDEX IF NOT EXISTS idx_statutory_provisions_statute ON statutory_provisions(statute_name);
CREATE INDEX IF NOT EXISTS idx_transition_phrases_block ON transition_phrases USING gin(block_types);
CREATE INDEX IF NOT EXISTS idx_lessons_category ON lessons_learned(category);
CREATE INDEX IF NOT EXISTS idx_paragraph_embeddings_vec
    ON paragraph_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
CREATE INDEX IF NOT EXISTS idx_case_law_embeddings_vec
    ON case_law_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
"""


# ── Phase 4: Methodology alignment ──────────────────────────────

SCHEMA_V4_SQL = """

-- ═══════════════════════════════════════════════════════════════════
-- V4: Methodology alignment (decision-methodology.md)
-- ═══════════════════════════════════════════════════════════════════

-- claims: טיפול בטענות (bundle/skip) + סוג טענה
ALTER TABLE claims ADD COLUMN IF NOT EXISTS claim_type TEXT DEFAULT 'claim';
    -- claim / response / reply
ALTER TABLE claims ADD COLUMN IF NOT EXISTS claim_handling TEXT DEFAULT 'address';
    -- address (דיון מלא) / bundle (קיבוץ) / skip (דילוג)
ALTER TABLE claims ADD COLUMN IF NOT EXISTS bundle_group TEXT DEFAULT '';
    -- שם הקבוצה לקיבוץ (למשל "פגמים פרוצדורליים")
ALTER TABLE claims ADD COLUMN IF NOT EXISTS handling_reason TEXT DEFAULT '';
    -- נימוק לדילוג/קיבוץ (למשל "נבחנה ולא מצאנו ממש")

-- cases: תקן ביקורת + קטגוריות נושא
ALTER TABLE cases ADD COLUMN IF NOT EXISTS standard_of_review TEXT DEFAULT '';
    -- "שיקול דעת תכנוני עצמאי" / "בחינת שומה מכרעת" / ...
ALTER TABLE cases ADD COLUMN IF NOT EXISTS subject_categories JSONB DEFAULT '[]';
    -- ["חניה", "קווי בניין", "גובה", "שימוש חורג", ...]

-- case_law: רמת תקדים + מעמד
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS precedent_level TEXT DEFAULT '';
    -- עליון / מנהלי / ועדת ערר ארצית / ועדת ערר מחוזית
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS is_binding BOOLEAN DEFAULT TRUE;
    -- הלכה מחייבת (true) / אמרת אגב (false)
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS creac_role TEXT DEFAULT '';
    -- rule (הנחה עליונה) / explanation (הרחבה) / analogy (אנלוגיה)

-- decisions: סדר סוגיות + תקן ביקורת
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS issue_order JSONB DEFAULT '[]';
    -- סדר הסוגיות שנקבע ע"י המנצח: [{"title": "...", "type": "threshold/dispositive/secondary"}]
ALTER TABLE decisions ADD COLUMN IF NOT EXISTS claim_handling JSONB DEFAULT '{}';
    -- {"overrides": [{"claim_id": "...", "handling": "bundle", "group": "..."}]}

-- indexes
CREATE INDEX IF NOT EXISTS idx_claims_handling ON claims(claim_handling);
CREATE INDEX IF NOT EXISTS idx_claims_type ON claims(claim_type);
CREATE INDEX IF NOT EXISTS idx_case_law_level ON case_law(precedent_level);
"""


# ── Phase 5: Interim draft (appraiser facts + post-hearing flag) ───

SCHEMA_V5_SQL = """

-- appraiser_facts: תכניות והיתרים שצוינו ע"י כל שמאי בנפרד.
-- בשונה מ-claims (שהוא טענה משפטית), כאן מאוחסנת עובדה עניינית מתוך השומה.
-- שימוש ראשי: זיהוי סתירות בין שמאים על איזו תכנית או היתר חל בנכס.
CREATE TABLE IF NOT EXISTS appraiser_facts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
    document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    appraiser_name TEXT NOT NULL,
    fact_type TEXT NOT NULL CHECK (fact_type IN ('plan', 'permit')),
    identifier TEXT NOT NULL,
    details JSONB NOT NULL DEFAULT '{}',
    page_number INTEGER,
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_appraiser_facts_case ON appraiser_facts(case_id, fact_type);
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_identifier ON appraiser_facts(case_id, identifier);

-- V5.1: appraiser_side — which party this appraiser represents.
-- Values: 'committee' (הוועדה), 'appellant' (העורר), 'deciding' (מכריע).
-- Required by extract_appraiser_facts; the chair tags it via the UI before extraction.
-- Set via documents.metadata.appraiser_side at upload/edit time, then propagated here
-- so that conflict rendering in block-tet can label each entry with its side.
ALTER TABLE appraiser_facts ADD COLUMN IF NOT EXISTS appraiser_side TEXT DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_appraiser_facts_side ON appraiser_facts(case_id, appraiser_side);

-- documents.metadata.is_post_hearing: flag for materials submitted after the hearing
-- (השלמות טיעון, הצעות פשרה). Used by block-chet to include them in the proceedings narrative.
-- documents.metadata.appraiser_side: which side the appraiser represents (see above).
-- No schema change needed — uses existing JSONB metadata column.
"""

# ── V6: Case archiving ────────────────────────────────────────────

SCHEMA_V6_SQL = """
-- archived_at: timestamp when the case was moved to the archive screen.
-- NULL = active (default). Set via POST /api/cases/{case_number}/archive.
-- Cleared via POST /api/cases/{case_number}/restore.
-- The /api/cases endpoint filters out archived cases by default;
-- pass ?include_archived=true (or use /api/cases/archived) to see them.
ALTER TABLE cases ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_cases_archived ON cases(archived_at) WHERE archived_at IS NOT NULL;
"""


# ── V7: External Precedent Library + halacha extraction ──────────
# Chair-uploaded external court rulings and other appeals committee decisions
# become an authoritative law corpus. Distinct from style_corpus (Daphna's
# style) and case_precedents (chair-attached quotes scoped to a single case).

SCHEMA_V7_SQL = """
-- case_law extensions: distinguish chair-uploaded full rulings from
-- auto-extracted citation stubs, and track ingestion progress.
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS source_kind TEXT DEFAULT 'cited_only';
    -- 'external_upload' (chair uploaded full ruling) | 'cited_only' (stub from
    -- references_extractor) | 'nevo_seed' (future: auto-fetched from Nevo).
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS document_id UUID REFERENCES documents(id) ON DELETE SET NULL;
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS extraction_status TEXT DEFAULT 'pending';
    -- 'pending' | 'processing' | 'completed' | 'failed'
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS halacha_extraction_status TEXT DEFAULT 'pending';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS metadata_extraction_status TEXT DEFAULT 'pending';
    -- 'pending' | 'processing' | 'completed' | 'failed'. Mirrors the
    -- text/halacha status columns so the UI can show a live badge while the
    -- local-MCP worker drains the metadata queue (previously only the
    -- metadata_extraction_requested_at timestamp existed — no 'processing').
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS practice_area TEXT DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS appeal_subtype TEXT DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS headnote TEXT DEFAULT '';
    -- chair-editable abstract shown in search results.
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS nevo_ratio TEXT DEFAULT '';
    -- The Nevo editorial מיני-רציו block, captured at ingest *before* it is
    -- stripped from the body (#86.3). Kept separate from `headnote` (which is
    -- our own abstract) so it can serve as a free professional gold-set for
    -- benchmarking halacha-extraction recall/precision. Empty when the source
    -- is not a Nevo export or carries no mini-ratio.
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS source_type TEXT DEFAULT '';
    -- 'court_ruling' | 'appeals_committee'

-- practice_area is closed to the three appeals committee domains.
DO $$ BEGIN
    ALTER TABLE case_law ADD CONSTRAINT case_law_practice_area_check
        CHECK (practice_area IN ('', 'rishuy_uvniya', 'betterment_levy', 'compensation_197'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

CREATE INDEX IF NOT EXISTS idx_case_law_source_kind ON case_law(source_kind);
CREATE INDEX IF NOT EXISTS idx_case_law_practice ON case_law(practice_area, appeal_subtype);

-- precedent_chunks: full-text chunks of an uploaded ruling, with embeddings.
-- Analog of document_chunks for case_law rows where source_kind='external_upload'.
CREATE TABLE IF NOT EXISTS precedent_chunks (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    chunk_index INTEGER NOT NULL,
    content TEXT NOT NULL,
    section_type TEXT DEFAULT 'other',
        -- intro | facts | legal_analysis | ruling | conclusion | other
    page_number INTEGER,
    embedding vector(1024),
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_case_law ON precedent_chunks(case_law_id);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_section ON precedent_chunks(case_law_id, section_type);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_vec
    ON precedent_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);

-- halachot: extracted binding rules. One halacha = one rule + verbatim quote.
-- Embedded separately for rule-precision semantic match (chunks centroid is
-- dominated by surrounding context). All halachot start as pending_review;
-- only approved/published rows are visible to search_precedent_library.
CREATE TABLE IF NOT EXISTS halachot (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    halacha_index INTEGER NOT NULL,
    rule_statement TEXT NOT NULL,
    rule_type TEXT DEFAULT 'interpretive',
        -- rule ROLE only (INV-DM7): holding | interpretive | procedural |
        -- application | obiter. authority (binding/persuasive) is DERIVED
        -- from case_law.precedent_level, never stored here.
    reasoning_summary TEXT DEFAULT '',
    supporting_quote TEXT NOT NULL,
    page_reference TEXT DEFAULT '',
    practice_areas TEXT[] DEFAULT '{}',
    subject_tags TEXT[] DEFAULT '{}',
    cites TEXT[] DEFAULT '{}',
    confidence NUMERIC(3,2) DEFAULT 0.0,
    quote_verified BOOLEAN DEFAULT FALSE,
    review_status TEXT DEFAULT 'pending_review',
        -- pending_review | approved | rejected | published | deferred (#84 snooze)
    reviewer TEXT DEFAULT '',
    reviewed_at TIMESTAMPTZ,
    quality_flags TEXT[] DEFAULT '{}',
        -- non_decision | truncated_quote | thin_restatement | quote_unverified
        -- (any flag blocks auto-approve → routes to pending_review)
    embedding vector(1024),
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE halachot ADD COLUMN IF NOT EXISTS quality_flags TEXT[] DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_halachot_case_law ON halachot(case_law_id);
CREATE INDEX IF NOT EXISTS idx_halachot_status ON halachot(review_status);
CREATE INDEX IF NOT EXISTS idx_halachot_practice ON halachot USING gin(practice_areas);
CREATE INDEX IF NOT EXISTS idx_halachot_tags ON halachot USING gin(subject_tags);
CREATE INDEX IF NOT EXISTS idx_halachot_vec
    ON halachot USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50);
-- #83: halacha_index must be unique per precedent. The extractor assigns it as
-- MAX(halacha_index)+1 under an in-process store-lock + a cross-process advisory
-- lock, so collisions shouldn't occur — but per FireHydrant/OneUptime the
-- constraint is the actual correctness guarantee (the lock is the optimization).
-- A racing/double run now fails LOUDLY instead of silently appending duplicates
-- (the 2026-05/06 over-extraction root cause). Requires clean data first (see
-- scripts: the 6 colliding precedents were renumbered 2026-06-03).
CREATE UNIQUE INDEX IF NOT EXISTS idx_halachot_unique_index
    ON halachot(case_law_id, halacha_index);
"""


# ── V8: Extraction request queue ─────────────────────────────────
# Web UI buttons ("Sparkles" = request metadata extraction; "Refresh" =
# request halacha extraction) run inside the FastAPI container, which has
# no `claude` CLI. They can't run the LLM extractor directly. Instead they
# stamp a request timestamp here, and the chair (or me) runs the MCP tool
# `precedent_process_pending_extractions` from local Claude Code, where the
# CLI is available, to drain the queue. See claude_session.py for the rule.

SCHEMA_V8_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS metadata_extraction_requested_at TIMESTAMPTZ;
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS halacha_extraction_requested_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_case_law_metadata_requested
    ON case_law(metadata_extraction_requested_at)
    WHERE metadata_extraction_requested_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_case_law_halacha_requested
    ON case_law(halacha_extraction_requested_at)
    WHERE halacha_extraction_requested_at IS NOT NULL;
"""


# ── V9: Multimodal page-image embeddings ─────────────────────────
# voyage-multimodal-3 (1024-dim) embeds the whole page as an image:
# captures table layout, scanned content, signatures, plans — content
# that text-OCR loses. Ingestion is gated by config.MULTIMODAL_ENABLED;
# search_*_hybrid() merge text-cosine + image-cosine when present.
# image_thumbnail_path is a relative path under DATA_DIR/cases/{case}/
# thumbnails/ or DATA_DIR/precedent-library/thumbnails/ — a small JPEG
# rendered at config.MULTIMODAL_THUMB_DPI for UI preview, distinct from
# the higher-DPI render fed to the embedder (which is not persisted).

SCHEMA_V9_SQL = """
CREATE TABLE IF NOT EXISTS document_image_embeddings (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
    case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    page_number INTEGER NOT NULL,
    image_thumbnail_path TEXT,
    embedding vector(1024),
    model_name TEXT DEFAULT 'voyage-multimodal-3',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(document_id, page_number)
);
CREATE INDEX IF NOT EXISTS idx_doc_img_emb_vec
    ON document_image_embeddings USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 50);
CREATE INDEX IF NOT EXISTS idx_doc_img_emb_doc
    ON document_image_embeddings(document_id);
CREATE INDEX IF NOT EXISTS idx_doc_img_emb_case
    ON document_image_embeddings(case_id);

CREATE TABLE IF NOT EXISTS precedent_image_embeddings (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    page_number INTEGER NOT NULL,
    image_thumbnail_path TEXT,
    embedding vector(1024),
    model_name TEXT DEFAULT 'voyage-multimodal-3',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(case_law_id, page_number)
);
CREATE INDEX IF NOT EXISTS idx_prec_img_emb_vec
    ON precedent_image_embeddings USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 50);
CREATE INDEX IF NOT EXISTS idx_prec_img_emb_case_law
    ON precedent_image_embeddings(case_law_id);
"""


SCHEMA_V10_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS district TEXT DEFAULT '';
ALTER TABLE cases ADD COLUMN IF NOT EXISTS chair_name TEXT DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_case_law_source_kind ON case_law(source_kind);
CREATE INDEX IF NOT EXISTS idx_case_law_chair ON case_law(chair_name) WHERE chair_name <> '';
CREATE INDEX IF NOT EXISTS idx_case_law_district ON case_law(district) WHERE district <> '';
"""

SCHEMA_V11_SQL = """
CREATE TABLE IF NOT EXISTS case_law_relations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
    related_id   UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
    relation_type TEXT NOT NULL DEFAULT 'same_case_chain',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE(case_law_id, related_id),
    CHECK (case_law_id <> related_id)
);
CREATE INDEX IF NOT EXISTS idx_clr_a ON case_law_relations(case_law_id);
CREATE INDEX IF NOT EXISTS idx_clr_b ON case_law_relations(related_id);
"""

# ── V12: BM25/lexical search via tsvector ─────────────────────────
# PostgreSQL doesn't ship a Hebrew stemmer; the 'simple' configuration
# lowercases + tokenises on whitespace without stemming — exactly what
# we want for Hebrew. It also preserves alphanumeric tokens like
# "1461/20" (case numbers) which are the prime motivator for adding a
# lexical layer on top of the semantic cosine index.
# Both columns are GENERATED STORED so they stay in sync with the
# source rows for free, and GIN-indexed for ts_rank_cd lookups.
SCHEMA_V12_SQL = """
ALTER TABLE precedent_chunks
    ADD COLUMN IF NOT EXISTS content_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;

ALTER TABLE halachot
    ADD COLUMN IF NOT EXISTS rule_tsv tsvector
    GENERATED ALWAYS AS (
        to_tsvector('simple',
            coalesce(rule_statement,'') || ' ' ||
            coalesce(supporting_quote,'') || ' ' ||
            coalesce(reasoning_summary,'')
        )
    ) STORED;

CREATE INDEX IF NOT EXISTS idx_precedent_chunks_tsv
    ON precedent_chunks USING GIN(content_tsv);

CREATE INDEX IF NOT EXISTS idx_halachot_tsv
    ON halachot USING GIN(rule_tsv);
"""


# ── V13: Missing precedents log ───────────────────────────────────
# Track citations that the parties brought up but which are NOT yet in
# the precedent_library. Created by the researcher (auto or chair)
# whenever a citation can't be found in the corpus; closed by uploading
# the actual decision via internal_decision_upload or
# precedent_library_upload, at which point linked_case_law_id points to
# the new case_law row and status flips to 'closed'.
SCHEMA_V13_SQL = """
CREATE TABLE IF NOT EXISTS missing_precedents (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    citation TEXT NOT NULL,
    case_name TEXT,
    cited_in_case_id UUID REFERENCES cases(id) ON DELETE CASCADE,
    cited_in_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
    cited_by_party TEXT CHECK (cited_by_party IN (
        'appellant', 'respondent', 'committee', 'permit_applicant', 'unknown'
    )),
    cited_by_party_name TEXT,
    legal_topic TEXT,
    legal_issue TEXT,
    claim_quote TEXT,
    status TEXT DEFAULT 'open' CHECK (status IN (
        'open', 'uploaded', 'closed', 'irrelevant'
    )),
    linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
    closed_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    notes TEXT
);

CREATE INDEX IF NOT EXISTS idx_missing_precedents_case
    ON missing_precedents(cited_in_case_id);
CREATE INDEX IF NOT EXISTS idx_missing_precedents_status
    ON missing_precedents(status);
CREATE INDEX IF NOT EXISTS idx_missing_precedents_citation
    ON missing_precedents(citation);
"""


# ── V14: Legal arguments (aggregated propositions) ────────────────
# After ``claims_extractor`` extracts raw propositions (rows in ``claims``)
# the LLM-driven aggregator groups them into ~6-12 distinct legal arguments
# per party. ``legal_arguments`` holds the consolidated argument; the M:M
# join table ``legal_argument_propositions`` links back to the source
# propositions for traceability ("which raw claims feed this argument?").
SCHEMA_V14_SQL = """
CREATE TABLE IF NOT EXISTS legal_arguments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
    party TEXT NOT NULL CHECK (party IN (
        'appellant', 'respondent', 'committee', 'permit_applicant', 'unknown'
    )),
    argument_index INTEGER NOT NULL,
    argument_title TEXT NOT NULL,
    argument_body TEXT NOT NULL,
    legal_topic TEXT,
    priority TEXT DEFAULT 'substantive' CHECK (priority IN (
        'threshold', 'substantive', 'procedural', 'relief'
    )),
    cited_precedents TEXT[],
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_legal_arguments_case
    ON legal_arguments(case_id);
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party
    ON legal_arguments(case_id, party);

-- M:M back to ``claims`` (raw propositions).
CREATE TABLE IF NOT EXISTS legal_argument_propositions (
    argument_id UUID NOT NULL REFERENCES legal_arguments(id) ON DELETE CASCADE,
    claim_id UUID NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
    PRIMARY KEY (argument_id, claim_id)
);
"""


# proceeding_type — מבחין בין הליך ערר עיקרי לבל"מ (בקשה להארכת מועד).
# חל גם על case_law (קורפוס) וגם על cases (תיקים חיים). שני הסוגים
# יכולים לחלוק אותו case_number, ולכן ה-uniqueness עוברת ל-(case_number,
# proceeding_type). בקורפוס: רק internal_committee מקבלים ערך מאוכלס;
# פסיקה חיצונית נשארת עם ''.
SCHEMA_V15_SQL = """
-- ------- case_law (קורפוס) -------
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS proceeding_type TEXT NOT NULL DEFAULT '';

ALTER TABLE case_law DROP CONSTRAINT IF EXISTS case_law_proceeding_type_check;
ALTER TABLE case_law ADD CONSTRAINT case_law_proceeding_type_check
    CHECK (proceeding_type IN ('', 'ערר', 'בל"מ'));

-- Backfill לפי appeal_subtype הקיים
UPDATE case_law SET proceeding_type = 'בל"מ'
 WHERE source_kind = 'internal_committee' AND proceeding_type = ''
   AND appeal_subtype LIKE 'extension_request_%';

UPDATE case_law SET proceeding_type = 'ערר'
 WHERE source_kind = 'internal_committee' AND proceeding_type = '';

ALTER TABLE case_law DROP CONSTRAINT IF EXISTS case_law_internal_proceeding_check;
ALTER TABLE case_law ADD CONSTRAINT case_law_internal_proceeding_check
    CHECK (source_kind != 'internal_committee' OR proceeding_type IN ('ערר', 'בל"מ'));

-- החלפת UNIQUE(case_number) ב-partial unique לפי source_kind
ALTER TABLE case_law DROP CONSTRAINT IF EXISTS case_law_case_number_key;
DROP INDEX IF EXISTS case_law_case_number_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_case_law_internal_number_proc
    ON case_law (case_number, proceeding_type)
    WHERE source_kind = 'internal_committee';
CREATE UNIQUE INDEX IF NOT EXISTS uq_case_law_external_number
    ON case_law (case_number)
    WHERE source_kind <> 'internal_committee';

-- ------- cases (תיקים חיים) -------
ALTER TABLE cases ADD COLUMN IF NOT EXISTS proceeding_type TEXT NOT NULL DEFAULT 'ערר';

ALTER TABLE cases DROP CONSTRAINT IF EXISTS cases_proceeding_type_check;
ALTER TABLE cases ADD CONSTRAINT cases_proceeding_type_check
    CHECK (proceeding_type IN ('ערר', 'בל"מ'));

UPDATE cases SET proceeding_type = 'בל"מ'
 WHERE proceeding_type = 'ערר' AND appeal_subtype LIKE 'extension_request_%';

ALTER TABLE cases DROP CONSTRAINT IF EXISTS cases_case_number_key;
DROP INDEX IF EXISTS cases_case_number_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_cases_number_proc
    ON cases (case_number, proceeding_type);
"""


# ── V16: Internal citations graph (TaskMaster #34) ────────────────
# Auto-extracted citation graph between Daphna's (and other internal_committee)
# decisions. When an internal decision cites another committee decision in a
# patterned way ("ונפנה ל…", "כפי שקבעתי…", "ראה החלטתי…"), the citation
# extractor records the link here. ``cited_case_law_id`` is populated when the
# cited case_number resolves to a row in ``case_law``; otherwise it stays NULL
# and shows up in ``idx_pic_unlinked`` so the chair can decide whether to
# upload the missing decision.
SCHEMA_V16_SQL = """
CREATE TABLE IF NOT EXISTS precedent_internal_citations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source_case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
    cited_case_number TEXT NOT NULL,
    cited_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
    match_context TEXT,
    match_pattern TEXT,
    confidence NUMERIC(3,2) DEFAULT 0.85,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (source_case_law_id, cited_case_number)
);
CREATE INDEX IF NOT EXISTS idx_pic_source
    ON precedent_internal_citations(source_case_law_id);
CREATE INDEX IF NOT EXISTS idx_pic_target
    ON precedent_internal_citations(cited_case_law_id);
CREATE INDEX IF NOT EXISTS idx_pic_unlinked
    ON precedent_internal_citations(cited_case_number)
    WHERE cited_case_law_id IS NULL;
"""


# ── V17: Parent-doc retrieval (TaskMaster #48) ─────────────────────
# Hierarchical chunking: tiny "child" chunks (~300 tokens) are indexed
# and matched at search time for high recall on focused phrases, but
# every child links upward to a larger "parent" chunk (~1500 tokens)
# that supplies broader context to the LLM. The retrieval step swaps
# the child hit for its parent before returning rows to callers — so
# rule statements, multi-paragraph quotes, and "אשר על כן…" passages
# come back whole instead of clipped mid-sentence.
#
# Schema layout:
#   parent_chunk_id  — self-FK on precedent_chunks. NULL for legacy
#                       rows (single-tier chunking) and for parent
#                       rows themselves. Cascade=SET NULL so deleting
#                       a parent doesn't orphan the children's payload.
#   chunk_role       — 'child' | 'parent'. Defaults to 'child' so any
#                       row created by the pre-V17 ingestion path is
#                       treated as a child without a parent (i.e. the
#                       parent-doc swap is a no-op and the legacy chunk
#                       continues to surface as-is).
#
# Activation is gated by ``config.PARENT_DOC_RETRIEVAL_ENABLED``. Even
# after the schema is in place, search keeps the legacy behaviour
# until both the chunker emits hierarchical chunks *and* the flag is
# flipped on — so this migration is safe to apply ahead of time.
SCHEMA_V17_SQL = """
ALTER TABLE precedent_chunks
    ADD COLUMN IF NOT EXISTS parent_chunk_id UUID
    REFERENCES precedent_chunks(id) ON DELETE SET NULL;

ALTER TABLE precedent_chunks
    ADD COLUMN IF NOT EXISTS chunk_role TEXT DEFAULT 'child';

DO $$ BEGIN
    ALTER TABLE precedent_chunks ADD CONSTRAINT precedent_chunks_role_check
        CHECK (chunk_role IN ('child', 'parent'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

CREATE INDEX IF NOT EXISTS idx_precedent_chunks_parent
    ON precedent_chunks(parent_chunk_id);
CREATE INDEX IF NOT EXISTS idx_precedent_chunks_role
    ON precedent_chunks(chunk_role);
"""


# ── V18: RAG telemetry — closed-loop retrieval feedback (TaskMaster #50)
#
# Captures every semantic search call (query, agent, top results,
# latency) so we can compute nDCG@10 over time and surface drift before
# it bites. Relevance signal comes from two places:
#   1. ``cited_in_decision`` — auto-inferred. If a precedent cited in a
#      final draft's ``decision_paragraphs.citations`` also appears in
#      the ``top_case_law_ids`` of a search log for the same case, that
#      hit is treated as highly relevant (score=3).
#   2. ``chair_marked`` — explicit feedback (future hook for the UI).
#
# ``top_case_law_ids`` is intentionally nullable: ``search_decisions``
# returns document chunks from active cases (not case_law rows), so its
# rows log the query but leave the array empty. nDCG aggregation skips
# those.
SCHEMA_V18_SQL = """
CREATE TABLE IF NOT EXISTS search_logs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    search_type TEXT NOT NULL,
        -- 'precedent_library' / 'internal_decisions'
        -- / 'decisions' / 'case_documents' / 'similar_cases'
    query TEXT NOT NULL,
    practice_area TEXT,
    case_id UUID REFERENCES cases(id) ON DELETE SET NULL,
    user_agent TEXT,
        -- 'writer' / 'researcher' / 'analyst' / 'manual' / 'unknown'
    result_count INTEGER,
    top_case_law_ids UUID[],
        -- nullable: empty for search_decisions/search_case_documents
        -- which return document chunks not case_law rows
    duration_ms INTEGER,
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_search_logs_type ON search_logs(search_type);
CREATE INDEX IF NOT EXISTS idx_search_logs_case ON search_logs(case_id);
CREATE INDEX IF NOT EXISTS idx_search_logs_date ON search_logs(created_at DESC);

CREATE TABLE IF NOT EXISTS search_relevance_feedback (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    search_log_id UUID REFERENCES search_logs(id) ON DELETE CASCADE,
    case_law_id UUID NOT NULL REFERENCES case_law(id) ON DELETE CASCADE,
    rank INTEGER NOT NULL,
        -- 1-based position in the original results (1 = top hit)
    relevance_score INTEGER NOT NULL
        CHECK (relevance_score IN (0, 1, 2, 3)),
        -- 0=irrelevant, 1=marginal, 2=relevant, 3=highly relevant
    feedback_source TEXT,
        -- 'cited_in_decision' / 'chair_marked' / 'auto_inferred'
    created_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE(search_log_id, case_law_id, feedback_source)
);
CREATE INDEX IF NOT EXISTS idx_relevance_log
    ON search_relevance_feedback(search_log_id);
CREATE INDEX IF NOT EXISTS idx_relevance_case_law
    ON search_relevance_feedback(case_law_id);
"""


# ── V19: case_law.citation_formatted ───────────────────────────────
# Full formal citation per the Israeli unified citation rules ("כללי
# הציטוט האחיד"). Stored as Markdown: parties wrapped in **…** so the
# copy-to-clipboard helper can render bold for Word/Docs while keeping
# the plain-text form readable.
#
# Example:
#   ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי
#   נ' הועדה המקומית לתכנון ובנייה תל אביב** (נבו 25.9.2025)
SCHEMA_V19_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS citation_formatted TEXT DEFAULT '';
"""


# ── V20: case-name / case-number lexical match ────────────────────
# RC-A fix: the V12 tsvectors cover only chunk *content* + halacha
# text, so a bare case-name query ("אגסי") matched decisions that
# *cite* the case rather than the case itself. case_name and
# case_number live on the parent case_law row, so we add a dedicated
# meta tsvector there and OR it into the lexical search — a name/number
# hit then surfaces all of that case's chunks + halachot. 'simple'
# config (no stemmer) preserves Hebrew names + alphanumeric case
# numbers like "81002-01-21" exactly as V12 does for content.
SCHEMA_V20_SQL = """
ALTER TABLE case_law
    ADD COLUMN IF NOT EXISTS meta_tsv tsvector
    GENERATED ALWAYS AS (
        to_tsvector('simple',
            coalesce(case_name,'') || ' ' || coalesce(case_number,'')
        )
    ) STORED;

CREATE INDEX IF NOT EXISTS idx_case_law_meta_tsv
    ON case_law USING GIN(meta_tsv);
"""


# ── V21: explicit `searchable` flag (GAP-13 / INV-DM1) ─────────────
# Materialized completeness flag — a case_law row is exposed to search only
# when it satisfies the completeness contract (02-data-model §2a). Recomputed
# on ingest/metadata completion via recompute_searchable(); not inferred at
# query time. Default false so a freshly-inserted row is excluded until proven
# complete. Health-check surfaces count(*) FILTER (WHERE NOT searchable).
SCHEMA_V21_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS searchable boolean NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS idx_case_law_searchable ON case_law (searchable);
"""


# ── V22: cases.blocks_stale — DOCX↔blocks drift flag (GAP-17 / INV-EX1) ──
# Set true when revise_draft/apply_user_edit make active_draft_path the live
# source-of-truth without re-syncing decision_blocks; cleared when blocks are
# re-exported or re-saved. Surfaced by health-check. Source-of-truth remains
# decision_blocks — this only flags known drift (no fragile DOCX→blocks reparse).
SCHEMA_V22_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS blocks_stale boolean NOT NULL DEFAULT false;
"""


# ── V23: case_law content/indexed hashes — re-index on content change (GAP-09) ──
# content_hash = SHA-256 of current full_text (written at the create boundary).
# indexed_hash = the content_hash the CURRENT chunks/embeddings were built from
# (set by mark_indexed after a successful store). Stale ⇔ content_hash IS
# DISTINCT FROM indexed_hash. embedding can't be a GENERATED column (needs an
# API call), so freshness is enforced by detection + reindex_case_law + health-check.
SCHEMA_V23_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS content_hash text NOT NULL DEFAULT '';
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS indexed_hash text;
"""


SCHEMA_V24_SQL = """
-- X11: citation corroboration (treatment + halacha-level link)
ALTER TABLE precedent_internal_citations
    ADD COLUMN IF NOT EXISTS treatment TEXT DEFAULT '';

CREATE TABLE IF NOT EXISTS halacha_citation_corroboration (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    citing_case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE,
    citing_decision_id UUID REFERENCES decisions(id) ON DELETE SET NULL,
    source_citation_id UUID NOT NULL,
    treatment TEXT NOT NULL,
    match_score NUMERIC(4,3) DEFAULT 0,
    match_context TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (halacha_id, source_citation_id)
);
CREATE INDEX IF NOT EXISTS idx_hcc_halacha ON halacha_citation_corroboration(halacha_id);
"""

SCHEMA_V25_SQL = """
-- Crash-safe halacha extraction: per-chunk checkpoint enables incremental store
-- + resume. A chunk with halacha_extracted_at set has been processed; a resumed
-- run skips it (so a crash never loses completed chunks or re-pays for them).
ALTER TABLE precedent_chunks
    ADD COLUMN IF NOT EXISTS halacha_extracted_at TIMESTAMPTZ;
"""

SCHEMA_V26_SQL = """
-- draft_final_pairs (T5 / INV-LRN4): the reconciliation ledger.
-- Every decision is "closed" only after it is compared against the chair's signed
-- final. Captures an immutable snapshot of the AI draft at mark-final time (before
-- it can be overwritten), paired with the final. The LLM distillation (curator)
-- fills final_text + diff_stats + analysis later and advances status.
CREATE TABLE IF NOT EXISTS draft_final_pairs (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
    draft_text TEXT NOT NULL DEFAULT '',
    final_path TEXT DEFAULT '',
    final_text TEXT DEFAULT '',
    diff_stats JSONB DEFAULT NULL,
    analysis JSONB DEFAULT NULL,
    -- final_received → analyzed → lessons_folded
    status TEXT NOT NULL DEFAULT 'final_received',
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_draft_final_pairs_case ON draft_final_pairs(case_id);
CREATE INDEX IF NOT EXISTS idx_draft_final_pairs_status ON draft_final_pairs(status);
"""

SCHEMA_V27_SQL = """
-- style_exemplars (T1-T3): block-level paragraphs from Dafna's OWN decisions
-- (style_corpus + internal_committee finals), embedded for retrieval as
-- style exemplars at write-time. Purpose-built so we DON'T fabricate synthetic
-- cases just to reuse decision_paragraphs. INV-LRN5: style material only — the
-- writer is told to adapt structure/voice, copy only boilerplate, never substance.
CREATE TABLE IF NOT EXISTS style_exemplars (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    decision_number TEXT DEFAULT '',
    source TEXT DEFAULT '',                 -- style_corpus | internal_committee
    practice_area TEXT DEFAULT '',
    outcome TEXT DEFAULT '',                -- rejection | partial_acceptance | full_acceptance | ''
    section TEXT DEFAULT 'other',           -- background | claims | discussion | summary | other
    paragraph_text TEXT NOT NULL,
    word_count INTEGER DEFAULT 0,
    embedding vector(1024),
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_style_exemplars_section ON style_exemplars(section);
CREATE INDEX IF NOT EXISTS idx_style_exemplars_decision ON style_exemplars(decision_number, source);
"""

SCHEMA_V28_SQL = """
-- equivalent_halachot (#84.2 follow-up): halacha-level PARALLEL-AUTHORITY links.
-- Distinct from halacha_citation_corroboration (X11): that records an actual
-- citation of a halacha by a later decision; this records that two halachot of
-- DIFFERENT precedents state the same legal principle INDEPENDENTLY (no citation
-- between them). Symmetric and non-directional — stored with halacha_a < halacha_b
-- so each pair is unique and self-links are impossible. Never merges/deletes the
-- halachot; it only relates them so the chair sees a principle recurs across
-- committees (a real-but-non-citation signal the citator must not fabricate).
CREATE TABLE IF NOT EXISTS equivalent_halachot (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    halacha_a UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    halacha_b UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    cosine NUMERIC(4,3) DEFAULT 0,
    note TEXT DEFAULT '',
    created_by TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now(),
    CHECK (halacha_a < halacha_b),
    UNIQUE (halacha_a, halacha_b)
);
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_a ON equivalent_halachot(halacha_a);
CREATE INDEX IF NOT EXISTS idx_equiv_halacha_b ON equivalent_halachot(halacha_b);
"""

SCHEMA_V29_SQL = """
-- halacha_goldset (#81.7/#81.8): an evaluation set. A stratified sample of
-- halachot labeled (is_holding / correct_type / quote_complete) so we can
-- measure the extraction validators' precision/recall and recalibrate the
-- auto-approve threshold.
-- LABELING — tri-model consensus (no man-in-the-loop, chair directive
-- 2026-06-11): the ground-truth label is the MAJORITY of three independent
-- model lineages (Opus / DeepSeek / Gemini), written here with
-- tagged_by='panel:opus+deepseek+gemini'. This is NOT circular: the validators
-- being measured (#81.8, compute_quality_flags / is_fact_dependent / …) are
-- RULE-BASED heuristics, a different method family from the LLM judges. Two
-- guards keep the consensus honest: (1) a SPLIT vote (no 2/3) writes NO label
-- (is_holding stays NULL → escalates to the chair, INV-G10), and (2) the
-- anonymization probe (anon_*) re-judges with case names masked to catch
-- memorization vs genuine reasoning. A human tag (goldset_tag, tagged_by=
-- 'chair') still overrides the panel for any item.
CREATE TABLE IF NOT EXISTS halacha_goldset (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    halacha_id UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    batch TEXT NOT NULL DEFAULT 'default',
    is_holding BOOLEAN,            -- NULL until tagged
    correct_type TEXT DEFAULT '',  -- binding | interpretive | obiter | application | ''
    quote_complete BOOLEAN,
    tagged_by TEXT DEFAULT '',
    tagged_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT now(),
    UNIQUE (halacha_id, batch)
);
CREATE INDEX IF NOT EXISTS idx_goldset_batch ON halacha_goldset(batch);

-- AI second-opinion (a QA aid, NOT ground truth): an INDEPENDENT local-LLM
-- judgment shown beside the human tag so the chair can spot disagreements and
-- reconsider. Independent of the rule-based validators that #81.8 measures, so
-- no circularity. Generated locally (claude_session); never auto-applied.
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_is_holding BOOLEAN;
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_correct_type TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_rationale TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ai_generated_at TIMESTAMPTZ;

-- Tri-model consensus labeling (#81.7, chair directive 2026-06-11). ai_* above
-- holds the Opus/claude vote (lineage 1); these hold the other two lineages,
-- plus the derived consensus, the per-item agreement, and the anonymization
-- probe. The consensus (when not split) is also written into is_holding /
-- correct_type with tagged_by='panel:...' so goldset_score reads it unchanged.
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ds_is_holding BOOLEAN;       -- DeepSeek
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ds_correct_type TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS ds_rationale TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS gm_is_holding BOOLEAN;       -- Gemini
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS gm_correct_type TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS gm_rationale TEXT DEFAULT '';
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS panel_agreement TEXT DEFAULT '';  -- 3/3 | 2/3 | split | incomplete
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS anon_is_holding BOOLEAN;     -- re-judge, case names masked
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS anon_stable BOOLEAN;         -- anon verdict == consensus
ALTER TABLE halacha_goldset ADD COLUMN IF NOT EXISTS panel_generated_at TIMESTAMPTZ;
"""


SCHEMA_V30_SQL = """
-- digests (X12): Ofer Toister daily "כל יום" one-pagers. A SECONDARY,
-- discovery-layer ("radar") source — NOT authoritative law. Kept in its OWN
-- table (never case_law) so it cannot pollute the precedent corpus, never
-- enters the halacha pipeline (INV-DIG2), and is never cited directly in a
-- decision (INV-DIG1). Its only job is to point the researcher at the
-- UNDERLYING ruling, which is ingested separately into case_law and cited from
-- there. linked_case_law_id is the bridge (INV-DIG3): filled once the
-- underlying ruling is in the library; NULL = an open knowledge gap.
CREATE TABLE IF NOT EXISTS digests (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    yomon_number TEXT NOT NULL DEFAULT '',          -- "5163"
    digest_date DATE,                               -- date of the yomon ISSUE
    publication TEXT NOT NULL DEFAULT 'כל יום',
    source_firm TEXT NOT NULL DEFAULT 'עפר טויסטר, עורכי דין',
    concept_tag TEXT NOT NULL DEFAULT '',           -- "שיקול הדעת המצומצם"
    headline_holding TEXT NOT NULL DEFAULT '',      -- bold subtitle = the holding
    analysis_text TEXT NOT NULL DEFAULT '',         -- the 1-2 page body (raw text)
    summary TEXT NOT NULL DEFAULT '',               -- 2-3 sentence LLM summary
    underlying_citation TEXT NOT NULL DEFAULT '',   -- 'עת"מ 46111-12-22 יכין-אפק...'
    underlying_court TEXT NOT NULL DEFAULT '',
    underlying_date DATE,                           -- date the RULING was given (≠ digest_date)
    underlying_judge TEXT NOT NULL DEFAULT '',
    practice_area TEXT NOT NULL DEFAULT '',          -- rishuy_uvniya/betterment_levy/compensation_197
    appeal_subtype TEXT NOT NULL DEFAULT '',
    subject_tags TEXT[] NOT NULL DEFAULT '{}',
    linked_case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
    embedding vector(1024),                          -- single vector of concept+headline+summary+analysis
    source_document_path TEXT NOT NULL DEFAULT '',   -- staged PDF path (rel to DATA_DIR)
    content_hash TEXT NOT NULL DEFAULT '',           -- sha256 of extracted text — idempotent upload
    extraction_status TEXT NOT NULL DEFAULT 'pending', -- pending/processing/completed/failed
    content_tsv tsvector GENERATED ALWAYS AS (
        to_tsvector('simple',
            coalesce(concept_tag,'') || ' ' || coalesce(headline_holding,'') || ' ' ||
            coalesce(summary,'') || ' ' || coalesce(analysis_text,''))
    ) STORED,
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);

-- Idempotent re-upload (INV-G3): same yomon number = same digest. yomon_number
-- can be '' transiently (before extraction), so the unique index is partial.
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_yomon_number
    ON digests(yomon_number) WHERE yomon_number <> '';
-- Secondary dedup key when yomon_number couldn't be parsed.
CREATE UNIQUE INDEX IF NOT EXISTS uq_digests_content_hash
    ON digests(content_hash) WHERE content_hash <> '';

-- HNSW (not ivfflat): the digests radar is a small, slowly-growing corpus
-- (~1/day). ivfflat trains `lists` centroids and probes a subset at query time,
-- so on a small table a single probe can hit an empty list and return 0 rows
-- (recall cliff). HNSW has no list-training/probe step — correct recall from
-- the first row — so it is the right index for a corpus that starts ~empty.
DROP INDEX IF EXISTS idx_digests_embedding;  -- drop any pre-existing ivfflat
CREATE INDEX IF NOT EXISTS idx_digests_embedding_hnsw
    ON digests USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS idx_digests_linked ON digests(linked_case_law_id);
CREATE INDEX IF NOT EXISTS idx_digests_practice_area ON digests(practice_area);
CREATE INDEX IF NOT EXISTS idx_digests_concept_tag ON digests(concept_tag);
CREATE INDEX IF NOT EXISTS idx_digests_subject_tags ON digests USING gin(subject_tags);
-- Lexical half of a future hybrid (Phase-1 search is semantic-only; index is ready).
CREATE INDEX IF NOT EXISTS idx_digests_content_tsv ON digests USING gin(content_tsv);
"""


# ── X13 — Court Verdict Fetch queue ──────────────────────────────────────
# A lightweight, observable, idempotent job queue for the auto-fetch
# subsystem (docs/spec/X13-court-fetch.md). One row per court verdict we try
# to pull from a public source. Mirrors the extraction-queue pattern: status
# is always explicit (INV-CF2 — no silent drop), the canonical case number is
# the idempotency key (INV-CF5), and ``attempts`` drives the human-fallback
# gate (INV-CF3 — flip to 'manual' after N autonomous failures).
# V31 — digests (X12) took V30 when it merged first.
SCHEMA_V31_SQL = """
CREATE TABLE IF NOT EXISTS court_fetch_jobs (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    case_number_norm TEXT NOT NULL UNIQUE,   -- idempotency key (INV-CF5)
    citation_raw TEXT NOT NULL DEFAULT '',
    tier TEXT NOT NULL DEFAULT '',           -- supreme | admin | skip
    court TEXT NOT NULL DEFAULT '',
    status TEXT NOT NULL DEFAULT 'pending',  -- pending|running|done|failed|manual
    attempts INT NOT NULL DEFAULT 0,
    error TEXT NOT NULL DEFAULT '',
    case_law_id UUID REFERENCES case_law(id) ON DELETE SET NULL,
    digest_id UUID,                          -- source digest (X12), nullable for ad-hoc
    source_url TEXT NOT NULL DEFAULT '',     -- provenance (INV-CF7)
    created_at TIMESTAMPTZ DEFAULT now(),
    updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_status ON court_fetch_jobs(status);
CREATE INDEX IF NOT EXISTS idx_court_fetch_jobs_digest ON court_fetch_jobs(digest_id)
    WHERE digest_id IS NOT NULL;
"""

SCHEMA_V32_SQL = """
-- digest_kind (X12): classify each "כל יום" issue. Most are decision-summaries
-- (point at a ruling → underlying_citation set), but some are non-decision
-- ANNOUNCEMENTS (legislative/planning updates, new-year notices) that legitimately
-- have no ruling. Classifying explicitly lets enrich treat an announcement as a
-- SUCCESS (concept+summary, no citation) instead of a both-empty "failure" that
-- the drain self-heal would retry forever. '' = not yet classified (= a genuine
-- extraction failure once enriched).
ALTER TABLE digests ADD COLUMN IF NOT EXISTS digest_kind TEXT NOT NULL DEFAULT '';
-- Backfill legacy rows cheaply (no LLM): a row with a citation is a decision,
-- otherwise an announcement. MUST run before the new self-heal keys on
-- digest_kind='' (else it would reset every legacy row). Idempotent.
UPDATE digests SET digest_kind =
    CASE WHEN coalesce(underlying_citation,'') <> '' THEN 'decision' ELSE 'announcement' END
    WHERE coalesce(digest_kind,'') = '' AND extraction_status = 'completed';
CREATE INDEX IF NOT EXISTS idx_digests_kind ON digests(digest_kind);
"""

SCHEMA_V33_SQL = """
-- drain_controls: a per-drain "startup type" switch for the /operations
-- dashboard's process-management panel. pm2 cron_restart resurrects a stopped
-- cron job at the next tick, so `pm2 stop` is NOT a durable "disable" for the
-- drains. Instead each drain checks this flag at startup and no-ops when
-- disabled (like a Windows service set to Disabled). The container writes it
-- directly (no host roundtrip); the drains read it. name = the pm2 process
-- name (e.g. 'legal-metadata-drain').
CREATE TABLE IF NOT EXISTS drain_controls (
    name        TEXT PRIMARY KEY,
    disabled    BOOLEAN NOT NULL DEFAULT false,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
"""

SCHEMA_V34_SQL = """
-- decision_lessons review gate (INV-LRN1/G10): a lesson must be chair-APPROVED
-- before it flows to the writer. The style panel writes proposals
-- (review_status='proposed'); get_recent_decision_lessons (writer-consumed) returns
-- only 'approved'. The chair approves/rejects in /training. Existing rows take the
-- column default — per chair decision (2026-06-11) all pre-gate lessons reset to
-- 'proposed' (nothing flows until re-approved).
ALTER TABLE decision_lessons
    ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'proposed';
CREATE INDEX IF NOT EXISTS idx_decision_lessons_review
    ON decision_lessons(review_status);
"""

SCHEMA_V35_SQL = """
-- halacha_panel_rounds (#133 / FU-1): captures EVERY 3-judge panel adjudication
-- so the active-learning loop has something to learn from. Until now the panel
-- (halacha_panel_approve.py) threw the per-judge votes and rationales away — only
-- the final review_status survived on `halachot`. Without the votes+reasons there
-- is no signal to mine ("panel said X, chair said Y") and no way to distil a better
-- decision rubric. One row per (halacha, round): the three lineages' vote+reason,
-- the derived verdict, and what the run did about it. This is a CAPTURE/audit table,
-- NOT a decision — it never changes a halacha's review_status (the chair gate on
-- /precedents stays the single source of truth, INV-G10). The learning seed is
-- formed later by joining this against the chair's decision on `halachot`
-- (reviewed_at > round_ts, reviewer='דפנה'). Modeled on halacha_goldset's panel
-- columns. question = which axis was judged ('keep' for clean bucket, 'entailed'
-- for nli). apply_mode=false means a dry-run produced the row (still kept — every
-- analysis is a learning datapoint); true means --apply acted on it.
CREATE TABLE IF NOT EXISTS halacha_panel_rounds (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    halacha_id      UUID NOT NULL REFERENCES halachot(id) ON DELETE CASCADE,
    round_ts        TIMESTAMPTZ NOT NULL,          -- one stamp shared by a whole run
    question        TEXT NOT NULL,                 -- 'keep' | 'entailed'
    bucket          TEXT NOT NULL DEFAULT '',      -- clean | nli | defect | other
    claude_vote     BOOLEAN, claude_reason   TEXT NOT NULL DEFAULT '',
    deepseek_vote   BOOLEAN, deepseek_reason TEXT NOT NULL DEFAULT '',
    gemini_vote     BOOLEAN, gemini_reason   TEXT NOT NULL DEFAULT '',
    verdict         TEXT NOT NULL DEFAULT '',      -- unanimous_yes|unanimous_no|split|incomplete
    applied_action  TEXT NOT NULL DEFAULT '',      -- approved|rejected|nli_cleared|chair|'' (dry-run)
    apply_mode      BOOLEAN NOT NULL DEFAULT false,
    created_at      TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_panel_rounds_halacha ON halacha_panel_rounds(halacha_id);
CREATE INDEX IF NOT EXISTS idx_panel_rounds_ts ON halacha_panel_rounds(round_ts);
"""

SCHEMA_V36_SQL = """
-- learning_run on draft_final_pairs: the voice-learning pipeline's RUN OUTCOME.
-- The pair's `status` (final_received→analyzed→lessons_folded) records how far the
-- DISTILLATION advanced, but not whether the run-learning button's pipeline
-- (scripts/final_learning_pipeline.py) actually completed or crashed mid-way — a
-- crash leaves status at final_received, indistinguishable from "never run". This
-- column stamps the explicit outcome so the case can SHOW "succeeded / failed +
-- reason / not-run". CAPTURE field on the existing INV-LRN4 ledger (not a parallel
-- tracking path, G1/G2). Shape: {status:'succeeded'|'failed', error, at, steps}.
-- NULL = the pipeline never recorded an outcome for this pair (treated as not-run).
ALTER TABLE draft_final_pairs ADD COLUMN IF NOT EXISTS learning_run JSONB;
"""

SCHEMA_V37_SQL = """
-- drain_controls.burst_until: a MANUAL, time-boxed "run continuously now" window
-- for a drain, managed interactively from /operations. While burst_until is in the
-- future the host supervisor (legal-halacha-supervisor) lifts the drain's
-- night-window and keeps it draining; NULL/past = the drain's normal schedule.
-- Chair-controlled — never set automatically. CAPTURE field on the existing
-- control table (G2 — no parallel control path).
ALTER TABLE drain_controls ADD COLUMN IF NOT EXISTS burst_until TIMESTAMPTZ;
"""

SCHEMA_V38_SQL = """
-- plans: canonical registry of planning schemes (תכניות בניין-עיר) reused across
-- cases. SSOT for a plan's IDENTITY + VALIDITY (date published למתן תוקף ברשומות +
-- ילקוט-הפרסומים number) + one-line purpose. DISTINCT from appraiser_facts, which
-- stays the per-appraiser, per-case factual snapshot (G2 — no parallel path): the
-- same plan recurs across many cases, so its validity lives here ONCE rather than
-- being re-derived from the appraisals every time. LLM-extracted rows enter
-- 'pending_review' and are NOT used in writing until the chair approves
-- (INV-DM5/G10 — mirrors halachot.review_status). The approved validity feeds
-- block-tet's DETERMINISTIC citation sentence (format_plan_citation), so dates are
-- never hallucinated by the writer (INV-AH). Identity = the normalized plan_number
-- (G1/INV-DM2); display_name/citation_formatted are derived display fields, never
-- the key.
CREATE TABLE IF NOT EXISTS plans (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    plan_number        TEXT NOT NULL UNIQUE,         -- canonical, normalized at write (G1)
    display_name       TEXT NOT NULL DEFAULT '',     -- surface form: "תכנית מי/820"
    aliases            TEXT[] NOT NULL DEFAULT '{}', -- other surface forms seen
    plan_type          TEXT NOT NULL DEFAULT '',     -- ארצית|מחוזית|מקומית|מפורטת|כוללנית|''
    gazette_date       DATE,                         -- פורסמה למתן תוקף ברשומות ביום…
    yalkut_number      TEXT NOT NULL DEFAULT '',     -- מס' ילקוט הפרסומים (י"פ)
    purpose            TEXT NOT NULL DEFAULT '',     -- one-line ייעוד
    citation_formatted TEXT NOT NULL DEFAULT '',     -- rendered canonical sentence (derived)
    review_status      TEXT NOT NULL DEFAULT 'pending_review'
        CHECK (review_status IN ('pending_review', 'approved', 'rejected')),  -- G10 gate
    -- provenance (INV-DM4/G9): where this record was learned from + by what model
    source_case_number TEXT NOT NULL DEFAULT '',
    source_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
    model_used         TEXT NOT NULL DEFAULT '',
    -- discrepancies: validity values that DIFFER from an already-approved row,
    -- captured for chair adjudication instead of silently overwriting (חוקה §6 —
    -- אין בליעה שקטה). Shape: [{field, old, new, source_case_number}].
    discrepancies      JSONB NOT NULL DEFAULT '[]',
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- lookup-only tsvector (G5 — this is a structured registry, NOT a 4th retrieval
    -- corpus; no embeddings). GENERATED → auto-maintained on content change (INV-DM3).
    meta_tsv tsvector GENERATED ALWAYS AS (
        to_tsvector('simple',
            coalesce(plan_number, '') || ' ' ||
            coalesce(display_name, '') || ' ' ||
            coalesce(purpose, ''))
    ) STORED
);
CREATE INDEX IF NOT EXISTS idx_plans_review ON plans(review_status);
CREATE INDEX IF NOT EXISTS idx_plans_meta_tsv ON plans USING gin(meta_tsv);
"""


# ── V39: case_law.parties ──────────────────────────────────────────
# The "עורר נ' משיב" line, extracted from the caption as a structured component.
# It is the re-derivable BASIS for the deterministic citation_formatted
# (format_precedent_citation) — the LLM extracts the party line (a reliable caption
# read) instead of formatting the whole Markdown citation, which it dropped outright
# (#145). citation_formatted stays a DERIVED display field (X1 §3 / INV-ID2); this
# column is its irreducible bold component.
SCHEMA_V39_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS parties TEXT DEFAULT '';
"""

# V40 (#143): missing_precedents gains a designator-aware dedup key
# (citation_norm) and a provenance tag (discovery_source: manual | cited_only |
# digest | writer). No UNIQUE constraint — the corpus legitimately holds the same
# docket across different courts (e.g. בג"ץ 389/87 vs ע"א 389/87), so uniqueness
# is enforced designator-aware in the create path, not by a DB constraint that
# would over-merge distinct precedents.
SCHEMA_V40_SQL = """
ALTER TABLE missing_precedents ADD COLUMN IF NOT EXISTS citation_norm TEXT DEFAULT '';
ALTER TABLE missing_precedents ADD COLUMN IF NOT EXISTS discovery_source TEXT DEFAULT 'manual';
CREATE INDEX IF NOT EXISTS idx_missing_precedents_citation_norm
    ON missing_precedents(citation_norm);
"""


# ── V41: canonical_halachot ──────────────────────────────────────────
# Replaces the equivalent_halachot bidirectional-link model (V28) with a
# first-class canonical entity. Instead of recording that halacha A ≡ halacha B,
# we now have ONE canonical_halachot row that BOTH A and B point to.
#
# Each legal PRINCIPLE is defined ONCE here (canonical_statement = LLM-
# synthesized abstraction, grounded in source statements per INV-AH). The
# per-precedent halachot rows become INSTANCES that link to the canonical and
# carry only their own quote, treatment, and context.
#
# Extraction pipeline change (Phase 3, separate PR): lookup-before-insert —
# embed new extraction, cosine-search canonical_halachot (≥0.85); if match,
# store a thin 'citation' instance; if not, create new canonical + 'original'
# instance. This eliminates per-extraction duplication of the same principle.
#
# INV-DM7: authority (binding/persuasive) derived from
#   first_established_in.precedent_level — never stored on canonical.
# INV-G10: only 'published' canonicals reach drafting agents.
# INV-AH: canonical_statement grounded in source statements, never invented;
#   review_status='pending_synthesis' until chair verifies.
# G2: equivalent_halachot (V28) deprecated post-backfill (no parallel path).
SCHEMA_V41_SQL = """
-- One row per unique legal principle across all precedents.
CREATE TABLE IF NOT EXISTS canonical_halachot (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    canonical_statement TEXT NOT NULL,
    rule_type TEXT NOT NULL DEFAULT 'interpretive',
    practice_areas TEXT[] NOT NULL DEFAULT '{}',
    subject_tags TEXT[] NOT NULL DEFAULT '{}',
    embedding vector(1024),
    review_status TEXT NOT NULL DEFAULT 'pending_synthesis'
        CHECK (review_status IN
            ('pending_synthesis','pending_review','approved','published','rejected')),
    first_established_in UUID REFERENCES case_law(id) ON DELETE SET NULL,
    instance_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_canonical_halachot_status
    ON canonical_halachot(review_status);
CREATE INDEX IF NOT EXISTS idx_canonical_halachot_practice
    ON canonical_halachot USING gin(practice_areas);
CREATE INDEX IF NOT EXISTS idx_canonical_halachot_tags
    ON canonical_halachot USING gin(subject_tags);
CREATE INDEX IF NOT EXISTS idx_canonical_halachot_vec
    ON canonical_halachot USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 30);

-- halachot: canonical linkage + role columns.
-- canonical_id: NULL until backfill_canonical_halachot.py runs; 100% filled after.
-- instance_type: role of this precedent's mention of the principle.
--   'original'   = the precedent that FIRST established the principle (source)
--   'citation'   = a later precedent that cites/applies the principle
--   'application'= a later precedent that applies the principle to new facts
-- treatment: how this precedent's mention relates to the canonical principle.
--   Parallels halacha_citation_corroboration.treatment (X11) but for precedents
--   (X11 tracks citations from internal decisions; this tracks per-precedent treatment).
-- rule_statement + embedding become nullable: citation instances inherit these
--   from canonical_halachot. 'original' instances keep their own stored values.
ALTER TABLE halachot
    ADD COLUMN IF NOT EXISTS canonical_id UUID
        REFERENCES canonical_halachot(id) ON DELETE SET NULL,
    ADD COLUMN IF NOT EXISTS instance_type TEXT NOT NULL DEFAULT 'original'
        CHECK (instance_type IN ('original','citation','application')),
    ADD COLUMN IF NOT EXISTS treatment TEXT NOT NULL DEFAULT 'mentioned';
ALTER TABLE halachot ALTER COLUMN rule_statement DROP NOT NULL;
ALTER TABLE halachot ALTER COLUMN embedding DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_halachot_canonical ON halachot(canonical_id);
CREATE INDEX IF NOT EXISTS idx_halachot_instance_type ON halachot(instance_type);

-- Importance layer (#153, component 1): principle-level gold/chair-cited flags.
-- gold_chair  = our chair (דפנה) cited THIS specific principle (tier-1, protective).
-- gold_digest = a digest's headline_holding matches this principle (tier-1).
-- chair_cited = another committee chair cited it (tier-2, weight not protection).
-- gold_match_score = best cosine of the matched signal (audit/tuning, G9).
-- All set by scripts/compute_principle_gold.py (embedding match, no LLM).
ALTER TABLE halachot
    ADD COLUMN IF NOT EXISTS gold_chair BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS gold_digest BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS chair_cited BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS gold_match_score REAL;
CREATE INDEX IF NOT EXISTS idx_halachot_gold ON halachot(gold_chair, gold_digest)
    WHERE gold_chair OR gold_digest;

-- Corpus redesign (#153, chaim 2026-06-20: "trusted = citation, not review").
-- Two-layer retrieval model:
--   • verified  = the principle's SOURCE precedent was actually cited by a chair
--                 (precedent_internal_citations). The ONLY trust signal — never
--                 from human review (the halacha review queue is eliminated).
--   • cite_count = # distinct chair decisions citing the source precedent → the
--                 importance/ranking signal (verified ≫ background at retrieval).
-- Refreshed by db.refresh_verified_layer() (scripts/build_verified_layer.py), and
-- grows automatically as new chair decisions are ingested (active-learning).
ALTER TABLE halachot
    ADD COLUMN IF NOT EXISTS verified BOOLEAN NOT NULL DEFAULT false,
    ADD COLUMN IF NOT EXISTS cite_count INT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_halachot_verified ON halachot(verified) WHERE verified;

-- halacha_citation_corroboration (X11) gains canonical_id so the signal
-- aggregates at the principle level rather than the per-instance level.
-- Backfill: UPDATE halacha_citation_corroboration SET canonical_id =
--   (SELECT canonical_id FROM halachot WHERE id = halacha_id).
-- halacha_id is retained for audit trail.
ALTER TABLE halacha_citation_corroboration
    ADD COLUMN IF NOT EXISTS canonical_id UUID
        REFERENCES canonical_halachot(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_hcc_canonical
    ON halacha_citation_corroboration(canonical_id)
    WHERE canonical_id IS NOT NULL;
"""


SCHEMA_V42_SQL = """
-- INV-DM7: authority (binding/persuasive) is STRUCTURAL for committee sources.
-- An appeals-committee decision (source_kind='internal_committee', or any row
-- with source_type='appeals_committee') is persuasive, NEVER binding — a
-- committee's ruling does not bind another committee, nor itself. is_binding
-- stays a stored column (it selects the halacha-extraction prompt), but for
-- committee sources it is a faithful cache of the derived value, not a chair
-- guess. Mirrors 02-data-model.md §INV-DM7 / X8-field-provenance.md.
-- Step 1 — normalize legacy rows at the source (G1), idempotent.
UPDATE case_law SET is_binding = FALSE
    WHERE is_binding IS TRUE
      AND (source_kind = 'internal_committee' OR source_type = 'appeals_committee');
-- Step 2 — constrain so a binding committee row can never be written again.
DO $$ BEGIN
    ALTER TABLE case_law ADD CONSTRAINT case_law_committee_not_binding_check
        CHECK (NOT ((source_kind = 'internal_committee'
                     OR source_type = 'appeals_committee') AND is_binding));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
"""


SCHEMA_V43_SQL = """
-- חיפה (Haifa) is a distinct planning district, separate from הצפון (North).
-- A bug in _district_from_court mapped "חיפה" -> "צפון", and the service-layer
-- _VALID_DISTRICTS omitted "חיפה" entirely, so Haifa committee decisions were
-- mis-filed under צפון. Both fixed in internal_decisions.py; this reclassifies
-- the legacy rows at the source (G1) by their court text. Idempotent.
UPDATE case_law SET district = 'חיפה'
    WHERE source_kind = 'internal_committee'
      AND district = 'צפון'
      AND court ILIKE '%חיפה%';
"""


SCHEMA_V44_SQL = """
-- Citation-verification panel (#154): the chair verifies each corpus precedent
-- against the specific legal ARGUMENT before the writer may cite it (INV-AH gate).
-- case_precedents gains: the argument it supports, the resolved case_law row
-- (for the cited_by authority signal + dedup), a verified flag (the gate), and a
-- verification timestamp. chair_note already exists. All nullable so legacy
-- section-scoped rows stay valid.
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS argument_id UUID
    REFERENCES legal_arguments(id) ON DELETE SET NULL;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS case_law_id UUID
    REFERENCES case_law(id) ON DELETE SET NULL;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified BOOLEAN DEFAULT false;
ALTER TABLE case_precedents ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_case_precedents_argument ON case_precedents(argument_id);
"""


# V45 (Path A — prospective held-out): a style-distance snapshot captured at
# final-upload time, BEFORE this case's lessons are folded. Because the draft was
# written with only the PRIOR lesson pool, each row is a clean generalization
# datapoint: "with N accumulated lessons, our draft on this unseen case scored X".
# pool_* record how much voice-learning had accumulated when the draft was made,
# so the trend (style-distance vs pool size / time) shows whether learning
# generalizes. Append-only; one row per final upload. (07-learning §0, INV-LRN4.)
SCHEMA_V45_SQL = """
CREATE TABLE IF NOT EXISTS style_distance_history (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_number TEXT NOT NULL,
    pair_id UUID REFERENCES draft_final_pairs(id) ON DELETE SET NULL,
    measured_at TIMESTAMPTZ DEFAULT now(),
    pool_discussion_rules INT DEFAULT 0,
    pool_transition_phrases INT DEFAULT 0,
    anti_pattern_total INT,
    ratio_max_deviation REAL,
    change_percent REAL
);
CREATE INDEX IF NOT EXISTS idx_style_distance_history_measured ON style_distance_history(measured_at);
"""

SCHEMA_V46_SQL = """
-- decision_lessons synthesis (#158 / INV-LRN8): consolidate overlapping lessons into
-- one richer "super-lesson" (source='synthesis', synthesized_from=[merged ids]); the
-- merged sources flip review_status='superseded' (kept as provenance, no longer fed to
-- the writer). embedding powers cosine clustering + the drift guard. Mirrors the
-- canonical-halacha synthesis (V41) ON THE SAME TABLE — no parallel store (G2).
ALTER TABLE decision_lessons ADD COLUMN IF NOT EXISTS embedding vector(1024);
ALTER TABLE decision_lessons ADD COLUMN IF NOT EXISTS synthesized_from UUID[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
    ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
"""

# V47 (WS2 / task #200): "primary document" (מסמך עיקרי) concept.
# `documents.is_primary` is a GENERATED ALWAYS … STORED column derived purely
# from `doc_type` against the canonical PRIMARY_DOC_TYPES list (built from the
# single-source tuple via _primary_doc_types_sql_array). Because Postgres
# computes it, there is NO parallel write path and it can never drift from
# doc_type (G1/G2/INV-DM7 — same drift-free pattern as the GENERATED tsvectors,
# INV-DM3). Idempotent: ADD COLUMN IF NOT EXISTS; the generated expression is
# fixed, so re-running is a no-op. The partial index serves the
# "primary docs not yet analysed" queries that #201 builds on.
SCHEMA_V47_SQL = f"""
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN
    GENERATED ALWAYS AS (doc_type = ANY({_primary_doc_types_sql_array()})) STORED;
CREATE INDEX IF NOT EXISTS idx_documents_primary
    ON documents(case_id) WHERE is_primary;
"""

# ── V48: Protocol comparative analysis (WS4 / #203) ───────────────
#
# protocol_analysis: case-knowledge derived from a hearing-protocol document by
# comparing the oral arguments raised at the hearing against the written
# pleadings (legal_arguments). One row per (case_id, document_id) — idempotent
# replace on re-run (INV-TOOL3). Each row holds the comparative verdict on a
# single argument: was it DROPPED at the hearing, STRENGTHENED, or NEWLY_RAISED,
# plus the sharpened legal question it bears on. This is *derived* knowledge —
# the source of truth is the protocol document + legal_arguments; the row is a
# materialized comparison, re-buildable from those (G2: no parallel store, this
# is derived-from-source not a competing claims table).
#
# header_data (the א–ד feed: panel, hearing date, parties present) is written
# back to the canonical `cases` columns (panel via decisions, hearing_date on
# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on
# the analysis row for provenance only (G9).
SCHEMA_V48_SQL = """
CREATE TABLE IF NOT EXISTS protocol_analysis (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE,
    document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    party_role TEXT NOT NULL DEFAULT '',          -- appellant/respondent/committee/permit_applicant/''
    change_type TEXT NOT NULL CHECK (change_type IN ('dropped', 'strengthened', 'newly_raised')),
    argument_id UUID REFERENCES legal_arguments(id) ON DELETE SET NULL,  -- the pleaded argument, when matched
    argument_title TEXT NOT NULL DEFAULT '',      -- snapshot of the argument / new point
    summary TEXT NOT NULL,                        -- what changed at the hearing, in prose
    sharpened_question TEXT NOT NULL DEFAULT '',  -- the legal question this sharpens (for the discussion)
    evidence_quote TEXT NOT NULL DEFAULT '',      -- verbatim protocol excerpt (INV-AH: quote-or-retract)
    page_number INTEGER,
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_id, change_type);
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
"""

# V49 (WS2 / task #201): per-document "claims analysed" tracking — the "not-analysed"
# flag. `claims_extracted_at` is stamped by claims_extractor AFTER a document's claims
# are stored; `claims_extraction_status` records the outcome
# ('pending' | 'completed' | 'no_claims' | 'failed'). These describe the per-document
# extraction *event* (the single source of truth for "has this doc been analysed") —
# NOT derived from doc_type. They let us detect a PRIMARY document (is_primary, V47)
# added after the analysis ran and not yet included → "primary doc not yet analysed".
# The partial index pairs with idx_documents_primary so that query is cheap. Idempotent:
# ADD COLUMN IF NOT EXISTS; re-running is a no-op. (V48 = protocol_analysis, #357.)
SCHEMA_V49_SQL = """
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extracted_at TIMESTAMPTZ;
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extraction_status TEXT
    NOT NULL DEFAULT 'pending';
CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
    ON documents(case_id) WHERE is_primary AND claims_extracted_at IS NULL;
"""


# V50 (#224): per-respondent-brief separation. The aggregator groups respondent /
# permit_applicant claims by their source pleading — each joint response brief
# (e.g. "כתב תשובה משיבות 2-3" vs "משיבים 4-6") is one coherent litigation
# position, so opposing briefs no longer collapse into a single "respondent"
# bucket. ``party_name`` carries the brief label; appellant/committee stay ''.
SCHEMA_V50_SQL = """
ALTER TABLE legal_arguments ADD COLUMN IF NOT EXISTS party_name TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS idx_legal_arguments_party_name
    ON legal_arguments(case_id, party, party_name);
"""


# V51 (#226): hearing-attendance provenance for the "מה קרה בדיון" panel. The
# ערר-hearing protocol names who actually appeared (עוררים/משיבים + their
# counsel) and, when stated, the presiding panel. ``_extract_header`` already
# extracts this feed but only ``hearing_date`` had a canonical home (on cases);
# the attendee lists were returned as provenance and dropped. This column gives
# them a home so the panel can display them.
#
# This is DISTINCT from ``decisions.panel_members`` (G2, not a parallel path):
# that column is the authoring tribunal recorded on a specific written-decision
# version (the DOCX signature block). ``hearing_attendees`` is a snapshot of who
# was present at the *hearing*, extracted from the protocol — hearing-event
# provenance, not decision-authorship. Shape:
#   {"panel_members": [...], "appellants_present": [...], "respondents_present": [...]}
SCHEMA_V51_SQL = """
ALTER TABLE cases ADD COLUMN IF NOT EXISTS hearing_attendees JSONB NOT NULL DEFAULT '{}';
"""


# V52 (#224): backfill claims.party_name for existing rows. The extractor now
# stamps party_name = source_document for the multi-litigant sides at write time
# (claims_extractor), but claims stored before that shipped have party_name=''.
# For those, the brief label is the source pleading — same rule the aggregator
# uses (respondent / permit_applicant split). Idempotent: only fills empties, so
# re-running is a no-op. Single-voice sides (appellant/committee) stay ''.
SCHEMA_V52_SQL = """
UPDATE claims SET party_name = source_document
WHERE party_role IN ('respondent', 'permit_applicant')
  AND COALESCE(party_name, '') = ''
  AND COALESCE(source_document, '') <> '';
"""


# Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes
# that fire at the same minute race on AccessExclusiveLock and Postgres kills one
# with DeadlockDetectedError. The lock makes a concurrent migrator wait instead.
_MIGRATION_LOCK_KEY = 778899001


async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
    async with pool.acquire() as conn:
        # Serialise DDL across processes: block until any sibling migrator
        # finishes, then run against the (now up-to-date, idempotent) schema.
        await conn.execute("SELECT pg_advisory_lock($1)", _MIGRATION_LOCK_KEY)
        try:
            await _apply_schema_ddl(conn)
        finally:
            await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
    logger.info("Database schema initialized (v1-v43)")


async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
        await conn.execute(SCHEMA_SQL)
        await conn.execute(MIGRATIONS_SQL)
        await conn.execute(SCHEMA_V2_SQL)
        await conn.execute(SCHEMA_V3_SQL)
        await conn.execute(SCHEMA_V4_SQL)
        await conn.execute(SCHEMA_V5_SQL)
        await conn.execute(SCHEMA_V6_SQL)
        await conn.execute(SCHEMA_V7_SQL)
        await conn.execute(SCHEMA_V8_SQL)
        await conn.execute(SCHEMA_V9_SQL)
        await conn.execute(SCHEMA_V10_SQL)
        await conn.execute(SCHEMA_V11_SQL)
        await conn.execute(SCHEMA_V12_SQL)
        await conn.execute(SCHEMA_V13_SQL)
        await conn.execute(SCHEMA_V14_SQL)
        await conn.execute(SCHEMA_V15_SQL)
        await conn.execute(SCHEMA_V16_SQL)
        await conn.execute(SCHEMA_V17_SQL)
        await conn.execute(SCHEMA_V18_SQL)
        await conn.execute(SCHEMA_V19_SQL)
        await conn.execute(SCHEMA_V20_SQL)
        await conn.execute(SCHEMA_V21_SQL)
        await conn.execute(SCHEMA_V22_SQL)
        await conn.execute(SCHEMA_V23_SQL)
        await conn.execute(SCHEMA_V24_SQL)
        await conn.execute(SCHEMA_V25_SQL)
        await conn.execute(SCHEMA_V26_SQL)
        await conn.execute(SCHEMA_V27_SQL)
        await conn.execute(SCHEMA_V28_SQL)
        await conn.execute(SCHEMA_V29_SQL)
        await conn.execute(SCHEMA_V30_SQL)
        await conn.execute(SCHEMA_V31_SQL)
        await conn.execute(SCHEMA_V32_SQL)
        await conn.execute(SCHEMA_V33_SQL)
        await conn.execute(SCHEMA_V34_SQL)
        await conn.execute(SCHEMA_V35_SQL)
        await conn.execute(SCHEMA_V36_SQL)
        await conn.execute(SCHEMA_V37_SQL)
        await conn.execute(SCHEMA_V38_SQL)
        await conn.execute(SCHEMA_V39_SQL)
        await conn.execute(SCHEMA_V40_SQL)
        await conn.execute(SCHEMA_V41_SQL)
        await conn.execute(SCHEMA_V42_SQL)
        await conn.execute(SCHEMA_V43_SQL)
        await conn.execute(SCHEMA_V44_SQL)
        await conn.execute(SCHEMA_V45_SQL)
        await conn.execute(SCHEMA_V46_SQL)
        await conn.execute(SCHEMA_V47_SQL)
        await conn.execute(SCHEMA_V48_SQL)
        await conn.execute(SCHEMA_V49_SQL)
        await conn.execute(SCHEMA_V50_SQL)
        await conn.execute(SCHEMA_V51_SQL)
        await conn.execute(SCHEMA_V52_SQL)


async def init_schema() -> None:
    """Backward-compatible wrapper. Schema init now runs lazily inside get_pool()."""
    await get_pool()


# ── Case CRUD ───────────────────────────────────────────────────────

async def create_case(
    case_number: str,
    title: str,
    appellants: list[str] | None = None,
    respondents: list[str] | None = None,
    subject: str = "",
    property_address: str = "",
    permit_number: str = "",
    committee_type: str = "ועדה מקומית",
    hearing_date: date | None = None,
    notes: str = "",
    expected_outcome: str = "",
    # Default "" — DB CHECK constraint accepts empty, the upstream tool
    # (cases.case_create) is responsible for deriving the domain value
    # from the case_number prefix before calling here.
    practice_area: str = "",
    appeal_subtype: str = "",
    proceeding_type: str = "ערר",
    # Default "" — resolved below to the committee chair (never stored empty).
    # internal_committee corpus copies of this case's final REQUIRE a chair
    # (DB constraint case_law_internal_chair_check); setting it at creation
    # (INV-G1, source) keeps the learning loop's precedent copy from failing.
    chair_name: str = "",
) -> dict:
    pool = await get_pool()
    case_id = uuid4()
    canonical_number = _canonical_case_number(case_number)
    resolved_chair = config.committee_chair_for_case(
        {"chair_name": chair_name}, canonical_number)
    async with pool.acquire() as conn:
        await conn.execute(
            """INSERT INTO cases (id, case_number, title, appellants, respondents,
               subject, property_address, permit_number, committee_type,
               hearing_date, notes, expected_outcome,
               practice_area, appeal_subtype, proceeding_type, chair_name)
               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)""",
            case_id, canonical_number, title,
            json.dumps(appellants or []),
            json.dumps(respondents or []),
            subject, property_address, permit_number, committee_type,
            hearing_date, notes, expected_outcome,
            practice_area, appeal_subtype, proceeding_type, resolved_chair,
        )
    return await get_case(case_id)


async def get_case(case_id: UUID) -> dict | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow("SELECT * FROM cases WHERE id = $1", case_id)
    if row is None:
        return None
    return _row_to_case(row)


async def set_active_draft_path(case_id: UUID, path: str | None) -> None:
    """Update the case's active_draft_path (the DOCX that is source of truth)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE cases SET active_draft_path = $1, updated_at = now() WHERE id = $2",
            path, case_id,
        )


async def get_active_draft_path(case_id: UUID) -> str | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT active_draft_path FROM cases WHERE id = $1", case_id,
        )
    return row["active_draft_path"] if row else None


async def mark_blocks_stale(case_id: UUID, stale: bool) -> None:
    """Flag/clear DOCX↔blocks drift for a case (GAP-17)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE cases SET blocks_stale = $1, updated_at = now() WHERE id = $2",
            stale, case_id,
        )


async def resolve_citation_case_law_ids(ids) -> dict:
    """Structural citation→corpus resolution (GAP-20 / INV-AUD3).

    Given case_law_id values referenced by a decision's citations/provenance,
    split into resolvable (exist in case_law) vs unresolvable.
    """
    resolved, unresolved = [], []
    pool = await get_pool()
    async with pool.acquire() as conn:
        for cid in ids:
            try:
                exists = await conn.fetchval(
                    "SELECT EXISTS(SELECT 1 FROM case_law WHERE id = $1)", cid)
            except Exception:
                exists = False
            (resolved if exists else unresolved).append(cid)
    return {"resolved": resolved, "unresolved": unresolved}


def _normalize_case_number(s: str) -> str:
    """Canonicalise a case number for tolerant lookup.

    Agents receive the number in many shapes — from a Paperclip issue
    title ("ערר 8137/24"), with a slash instead of a dash, padded, or with
    surrounding whitespace. Stored values are bare ("8137-24"). Without
    this, get_case_by_number's exact match silently fails and the agent
    concludes the case has no documents (see #58). Strategy: drop any
    leading proceeding-type prefix (everything before the first digit),
    trim, and unify '/' → '-'.
    """
    s = (s or "").strip()
    m = re.search(r"\d", s)
    if m:
        s = s[m.start():]
    return s.strip().replace("/", "-")


def _canonical_case_number(s: str) -> str:
    """Canonical write-time form per X1 §1: trim · prefix-strip · '/'→'-'.

    Deterministic and format-only — does NOT add or remove a month segment.
    Used at the write boundary for identifier-keyed corpora (internal
    committee decisions, active cases). NOT for external precedents, whose
    canonical identifier is the full citation.

    Extracts the case-number TOKEN — the leading run of digits and internal
    separators after the proceeding-type prefix — and drops any trailing display
    text. Without this, a full citation mis-passed as the identifier (e.g.
    ``ערר (ת"א 85074-04-25) רפאל לוי נ' …``) left party names glued to the number
    (``85074-04-25) רפאל לוי …``), breaking equality lookups (#137, INV-ID2). A
    clean number is returned unchanged.
    """
    s = (s or "").strip()
    m = re.search(r"\d", s)
    if not m:
        return ""
    s = s[m.start():]
    token = re.match(r"\d[\d/\-]*", s)
    if token:
        s = token.group(0)
    return s.strip().rstrip("/-").replace("/", "-")


def _content_hash(text: str) -> str:
    """SHA-256 hex of the text — deterministic content fingerprint (FU-3/GAP-09).

    Empty/None → "" (a row with no text has no content fingerprint).
    """
    if not text:
        return ""
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


async def get_case_by_number(case_number: str) -> dict | None:
    pool = await get_pool()
    norm = _normalize_case_number(case_number)
    async with pool.acquire() as conn:
        # Exact match first (fast path + preferred); fall back to a
        # separator/prefix-normalised comparison so common formatting
        # variants still resolve to the right case. See #58.
        row = await conn.fetchrow(
            """SELECT * FROM cases
               WHERE case_number = $1
                  OR replace(btrim(case_number), '/', '-') = $2
               ORDER BY (case_number = $1) DESC, created_at
               LIMIT 1""",
            case_number, norm,
        )
    if row is None:
        return None
    return _row_to_case(row)


async def list_cases(
    status: str | None = None,
    limit: int = 50,
    include_archived: bool = False,
    archived_only: bool = False,
) -> list[dict]:
    pool = await get_pool()
    where = []
    args: list = []
    if status:
        where.append(f"status = ${len(args) + 1}")
        args.append(status)
    if archived_only:
        where.append("archived_at IS NOT NULL")
    elif not include_archived:
        where.append("archived_at IS NULL")
    where_clause = f"WHERE {' AND '.join(where)}" if where else ""
    args.append(limit)
    # Archived list is ordered by archive date (newest-first) so the order is
    # server-authoritative and the /archive page is correct on load, regardless
    # of client-side sorting. NULLS LAST is defensive — the archived_only filter
    # already excludes NULLs.
    order_by = "archived_at DESC NULLS LAST" if archived_only else "updated_at DESC"
    sql = f"SELECT * FROM cases {where_clause} ORDER BY {order_by} LIMIT ${len(args)}"
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *args)
    return [_row_to_case(r) for r in rows]


async def update_case(case_id: UUID, **fields) -> dict | None:
    if not fields:
        return await get_case(case_id)
    pool = await get_pool()
    set_clauses = []
    values = []
    for i, (key, val) in enumerate(fields.items(), start=2):
        if key in ("appellants", "respondents", "tags", "hearing_attendees"):
            val = json.dumps(val)
        set_clauses.append(f"{key} = ${i}")
        values.append(val)
    set_clauses.append("updated_at = now()")
    sql = f"UPDATE cases SET {', '.join(set_clauses)} WHERE id = $1"
    async with pool.acquire() as conn:
        await conn.execute(sql, case_id, *values)
    return await get_case(case_id)


def _row_to_case(row: asyncpg.Record) -> dict:
    d = dict(row)
    for field in ("appellants", "respondents", "tags", "hearing_attendees"):
        if isinstance(d.get(field), str):
            d[field] = json.loads(d[field])
    d["id"] = str(d["id"])
    return d


async def archive_case(case_id: UUID) -> dict | None:
    """Mark a case as archived. Returns updated row, or None if not found."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "UPDATE cases SET archived_at = now(), updated_at = now() "
            "WHERE id = $1 RETURNING *",
            case_id,
        )
    return _row_to_case(row) if row else None


async def restore_case(case_id: UUID) -> dict | None:
    """Clear the archived_at timestamp. Returns updated row, or None if not found."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "UPDATE cases SET archived_at = NULL, updated_at = now() "
            "WHERE id = $1 RETURNING *",
            case_id,
        )
    return _row_to_case(row) if row else None


async def delete_case(case_id: UUID) -> bool:
    """Delete a case row. Returns True if a row was actually removed.

    All dependent rows are removed automatically by FK constraints:
      • CASCADE: documents, document_chunks, claims, appraiser_facts,
        decisions, qa_results, case_precedents
      • SET NULL: audit_log.case_id, chair_feedback.case_id

    NOTE: this only touches the legal-ai database. The Paperclip project
    (issues, comments, runs) and Gitea repo for the case live in other
    systems and are NOT cleaned up here — call sites that need a full
    reset must handle those separately.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        result = await conn.execute("DELETE FROM cases WHERE id = $1", case_id)
    # asyncpg execute returns "DELETE <n>" — extract count.
    return int(result.split()[-1]) > 0


# ── Document CRUD ───────────────────────────────────────────────────

async def create_document(
    case_id: UUID,
    doc_type: str,
    title: str,
    file_path: str,
    page_count: int | None = None,
    content_hash: str = "",
) -> dict:
    pool = await get_pool()
    doc_id = uuid4()
    async with pool.acquire() as conn:
        await conn.execute(
            """INSERT INTO documents (id, case_id, doc_type, title, file_path, page_count, content_hash)
               VALUES ($1, $2, $3, $4, $5, $6, $7)""",
            doc_id, case_id, doc_type, title, file_path, page_count, content_hash,
        )
        row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
    return _row_to_doc(row)


async def get_document_by_hash(case_id: UUID, content_hash: str) -> dict | None:
    """Return an existing document for this case with the same file hash, or None.

    INV-TOOL3 / GAP-52: deterministic key for idempotent upload. Empty hashes
    (legacy rows) are never matched.
    """
    if not content_hash:
        return None
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT * FROM documents WHERE case_id = $1 AND content_hash = $2 LIMIT 1",
            case_id, content_hash,
        )
    return _row_to_doc(row) if row else None


async def update_document(doc_id: UUID, **fields) -> None:
    if not fields:
        return
    pool = await get_pool()
    set_clauses = []
    values = []
    for i, (key, val) in enumerate(fields.items(), start=2):
        if key == "metadata":
            val = json.dumps(val)
        set_clauses.append(f"{key} = ${i}")
        values.append(val)
    sql = f"UPDATE documents SET {', '.join(set_clauses)} WHERE id = $1"
    async with pool.acquire() as conn:
        await conn.execute(sql, doc_id, *values)


async def get_document(doc_id: UUID) -> dict | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow("SELECT * FROM documents WHERE id = $1", doc_id)
    return _row_to_doc(row) if row else None


async def list_documents(case_id: UUID) -> list[dict]:
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT * FROM documents WHERE case_id = $1 ORDER BY created_at", case_id
        )
    return [_row_to_doc(r) for r in rows]


async def get_document_text(doc_id: UUID) -> str:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT extracted_text FROM documents WHERE id = $1", doc_id
        )
    return row["extracted_text"] if row else ""


def _row_to_doc(row: asyncpg.Record) -> dict:
    d = dict(row)
    d["id"] = str(d["id"])
    d["case_id"] = str(d["case_id"])
    if isinstance(d.get("metadata"), str):
        d["metadata"] = json.loads(d["metadata"])
    # Primary/secondary classification (WS2 / #200). `is_primary` is the
    # generated DB column (V47); derive it at read-time too so the field is
    # always present even on rows fetched before the migration ran, and expose
    # a human-facing `doc_category`. Single source of truth: PRIMARY_DOC_TYPES.
    is_primary = bool(d["is_primary"]) if d.get("is_primary") is not None \
        else is_primary_doc_type(d.get("doc_type"))
    d["is_primary"] = is_primary
    d["doc_category"] = "primary" if is_primary else "secondary"
    # Per-document claims-analysis state (WS2 / #201). `claims_analyzed` is the
    # human-facing "has this doc been included in the claims analysis" flag —
    # True iff claims extraction has been stamped for it. Single source of truth:
    # claims_extracted_at. Defaults keep pre-migration rows well-formed.
    extracted_at = d.get("claims_extracted_at")
    if "claims_extraction_status" not in d or d.get("claims_extraction_status") is None:
        d["claims_extraction_status"] = "pending"
    d["claims_analyzed"] = extracted_at is not None
    return d


# ── Claims ─────────────────────────────────────────────────────────

async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int:
    """Store extracted claims. Replaces existing claims from same source.

    Each claim dict: party_role, claim_text, claim_index, party_name (optional)
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        if source_document:
            await conn.execute(
                "DELETE FROM claims WHERE case_id = $1 AND source_document = $2",
                case_id, source_document,
            )
        for claim in claims:
            await conn.execute(
                """INSERT INTO claims (case_id, party_role, party_name, claim_text, claim_index, source_document, claim_type)
                   VALUES ($1, $2, $3, $4, $5, $6, $7)""",
                case_id,
                claim["party_role"],
                claim.get("party_name", ""),
                claim["claim_text"],
                claim.get("claim_index", 0),
                source_document,
                claim.get("claim_type", "claim"),
            )
    return len(claims)


async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]:
    """Get claims for a case, optionally filtered by party role."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        if party_role:
            rows = await conn.fetch(
                "SELECT * FROM claims WHERE case_id = $1 AND party_role = $2 ORDER BY claim_index",
                case_id, party_role,
            )
        else:
            rows = await conn.fetch(
                "SELECT * FROM claims WHERE case_id = $1 ORDER BY party_role, claim_index",
                case_id,
            )
    return [dict(r) for r in rows]


# ── Per-document claims-analysis tracking (WS2 / #201) ──────────────

async def mark_document_claims_extracted(
    doc_id: UUID, status: str = "completed",
) -> None:
    """Stamp a document as having had its claims extracted (WS2 / #201).

    Sets ``claims_extracted_at = now()`` and records the outcome in
    ``claims_extraction_status`` ('completed' | 'no_claims' | 'failed').
    This is the single source of truth for the per-document "not-analysed"
    flag — it is written at the point claims are stored, not derived later.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE documents SET claims_extracted_at = now(), "
            "claims_extraction_status = $2 WHERE id = $1",
            doc_id, status,
        )


async def primary_docs_not_analyzed(case_id: UUID) -> list[dict]:
    """Return PRIMARY documents in a case that have NOT yet been analysed.

    "Primary" = ``is_primary`` (the V47 generated column, single source of
    truth = PRIMARY_DOC_TYPES). "Not analysed" = ``claims_extracted_at IS NULL``.
    Backed by the partial index ``idx_documents_claims_pending``. Surfaces the
    "there is a primary document not yet included in the analysis" condition
    (#201). Returns the same shape as ``list_documents`` rows.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT * FROM documents "
            "WHERE case_id = $1 AND is_primary AND claims_extracted_at IS NULL "
            "ORDER BY created_at",
            case_id,
        )
    return [_row_to_doc(r) for r in rows]


# ── Decisions ──────────────────────────────────────────────────────

async def create_decision(
    case_id: UUID,
    outcome: str = "",
    outcome_summary: str = "",
    outcome_reasoning: str = "",
    direction_doc: dict | None = None,
) -> dict:
    """Create a decision record for a case."""
    pool = await get_pool()
    decision_id = uuid4()
    async with pool.acquire() as conn:
        # Check if a decision already exists for this case
        existing = await conn.fetchrow(
            "SELECT id, version FROM decisions WHERE case_id = $1 ORDER BY version DESC LIMIT 1",
            case_id,
        )
        version = (existing["version"] + 1) if existing else 1

        await conn.execute(
            """INSERT INTO decisions (id, case_id, version, outcome, outcome_summary,
                                     outcome_reasoning, direction_doc)
               VALUES ($1, $2, $3, $4, $5, $6, $7)""",
            decision_id, case_id, version, outcome, outcome_summary,
            outcome_reasoning, json.dumps(direction_doc) if direction_doc else None,
        )
    return await get_decision(decision_id)


async def get_decision(decision_id: UUID) -> dict | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow("SELECT * FROM decisions WHERE id = $1", decision_id)
    if not row:
        return None
    d = dict(row)
    d["id"] = str(d["id"])
    d["case_id"] = str(d["case_id"])
    if isinstance(d.get("direction_doc"), str):
        d["direction_doc"] = json.loads(d["direction_doc"])
    if isinstance(d.get("panel_members"), str):
        d["panel_members"] = json.loads(d["panel_members"])
    return d


async def get_decision_by_case(case_id: UUID) -> dict | None:
    """Get the latest decision for a case."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT * FROM decisions WHERE case_id = $1 ORDER BY version DESC LIMIT 1",
            case_id,
        )
    if not row:
        return None
    d = dict(row)
    d["id"] = str(d["id"])
    d["case_id"] = str(d["case_id"])
    if isinstance(d.get("direction_doc"), str):
        d["direction_doc"] = json.loads(d["direction_doc"])
    if isinstance(d.get("panel_members"), str):
        d["panel_members"] = json.loads(d["panel_members"])
    return d


async def get_critical_qa_failures(case_id: UUID) -> list[dict]:
    """Return critical-severity failures from the case's latest QA run.

    ``qa_results`` is cleared+rewritten per ``validate_decision`` run, so the
    current rows for a ``case_id`` ARE the latest run. Returns rows where
    ``severity='critical' AND passed=false``. Callers distinguish "no QA run
    yet" (no rows at all) via ``qa_run_exists`` below.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """SELECT check_name, severity, passed, errors
               FROM qa_results
               WHERE case_id = $1 AND severity = 'critical' AND passed = false
               ORDER BY check_name""",
            case_id,
        )
    return [dict(r) for r in rows]


async def qa_run_exists(case_id: UUID) -> bool:
    """True if a QA run has ever been recorded for this case (any rows)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        n = await conn.fetchval(
            "SELECT count(*) FROM qa_results WHERE case_id = $1",
            case_id,
        )
    return bool(n)


async def update_decision(decision_id: UUID, **fields) -> None:
    if not fields:
        return
    pool = await get_pool()
    set_clauses = []
    values = []
    for i, (key, val) in enumerate(fields.items(), start=2):
        if key in ("direction_doc", "panel_members") and isinstance(val, (dict, list)):
            val = json.dumps(val)
        set_clauses.append(f"{key} = ${i}")
        values.append(val)
    set_clauses.append("updated_at = now()")
    sql = f"UPDATE decisions SET {', '.join(set_clauses)} WHERE id = $1"
    async with pool.acquire() as conn:
        await conn.execute(sql, decision_id, *values)


# ── Document deletion ──────────────────────────────────────────────

async def delete_document(doc_id: UUID) -> bool:
    """Delete a document and all its chunks. Returns True if deleted."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "DELETE FROM document_chunks WHERE document_id = $1", doc_id
            )
            result = await conn.execute(
                "DELETE FROM documents WHERE id = $1", doc_id
            )
            return int(result.split()[-1]) > 0


# ── Chunks & Vectors ───────────────────────────────────────────────

async def delete_document_chunks(document_id: UUID) -> int:
    """Delete all chunks for a document (used before reprocessing)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        result = await conn.execute(
            "DELETE FROM document_chunks WHERE document_id = $1", document_id
        )
        return int(result.split()[-1])  # e.g. "DELETE 5" -> 5


async def store_chunks(
    document_id: UUID,
    case_id: UUID | None,
    chunks: list[dict],
) -> int:
    """Store document chunks with embeddings. Each chunk dict has:
    content, section_type, embedding (list[float]), page_number, chunk_index
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        # Delete existing chunks for this document
        await conn.execute(
            "DELETE FROM document_chunks WHERE document_id = $1", document_id
        )
        for chunk in chunks:
            await conn.execute(
                """INSERT INTO document_chunks
                   (document_id, case_id, chunk_index, content, section_type, embedding, page_number)
                   VALUES ($1, $2, $3, $4, $5, $6, $7)""",
                document_id, case_id,
                chunk["chunk_index"],
                chunk["content"],
                chunk.get("section_type", "other"),
                chunk["embedding"],
                chunk.get("page_number"),
            )
    return len(chunks)


async def search_similar(
    query_embedding: list[float],
    limit: int = 10,
    case_id: UUID | None = None,
    section_type: str | None = None,
    practice_area: str | None = None,
    appeal_subtype: str | None = None,
) -> list[dict]:
    """Cosine similarity search on document chunks."""
    pool = await get_pool()
    conditions = []
    params: list = [query_embedding, limit]
    param_idx = 3

    if case_id:
        conditions.append(f"dc.case_id = ${param_idx}")
        params.append(case_id)
        param_idx += 1
    if section_type:
        conditions.append(f"dc.section_type = ${param_idx}")
        params.append(section_type)
        param_idx += 1
    if practice_area:
        conditions.append(f"c.practice_area = ${param_idx}")
        params.append(practice_area)
        param_idx += 1
    if appeal_subtype:
        conditions.append(f"c.appeal_subtype = ${param_idx}")
        params.append(appeal_subtype)
        param_idx += 1

    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""

    sql = f"""
        SELECT dc.content, dc.section_type, dc.page_number,
               dc.document_id, dc.case_id,
               d.title AS document_title,
               c.case_number,
               1 - (dc.embedding <=> $1) AS score
        FROM document_chunks dc
        JOIN documents d ON d.id = dc.document_id
        JOIN cases c ON c.id = dc.case_id
        {where}
        ORDER BY dc.embedding <=> $1
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *params)
    return [dict(r) for r in rows]


# ── Style corpus ────────────────────────────────────────────────────

async def add_to_style_corpus(
    document_id: UUID | None,
    decision_number: str,
    decision_date: date | None,
    subject_categories: list[str],
    full_text: str,
    summary: str = "",
    outcome: str = "",
    key_principles: list[str] | None = None,
    practice_area: str = "appeals_committee",
    appeal_subtype: str = "",
) -> UUID:
    pool = await get_pool()
    corpus_id = uuid4()
    async with pool.acquire() as conn:
        await conn.execute(
            """INSERT INTO style_corpus
               (id, document_id, decision_number, decision_date,
                subject_categories, full_text, summary, outcome, key_principles,
                practice_area, appeal_subtype)
               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)""",
            corpus_id, document_id, decision_number, decision_date,
            json.dumps(subject_categories), full_text, summary, outcome,
            json.dumps(key_principles or []),
            practice_area, appeal_subtype,
        )
    return corpus_id


async def delete_from_style_corpus(corpus_id: UUID) -> dict:
    """Remove a decision from style_corpus + related documents (cascades chunks).

    Also tries to delete the [קורפוס] document associated by title match,
    since the current training pipeline inserts style_corpus with document_id=NULL.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                "DELETE FROM style_corpus WHERE id = $1 "
                "RETURNING decision_number, document_id",
                corpus_id,
            )
            if not row:
                return {"deleted": False, "reason": "not found"}

            docs_deleted = 0
            if row["document_id"]:
                await conn.execute(
                    "DELETE FROM documents WHERE id = $1", row["document_id"]
                )
                docs_deleted = 1
            else:
                # Best-effort: match a [קורפוס] document by the decision_number
                # in its title. Only for single, unambiguous matches.
                if row["decision_number"]:
                    docs = await conn.fetch(
                        "SELECT id FROM documents "
                        "WHERE case_id IS NULL AND title LIKE $1",
                        f"%{row['decision_number']}%",
                    )
                    if len(docs) == 1:
                        await conn.execute(
                            "DELETE FROM documents WHERE id = $1", docs[0]["id"]
                        )
                        docs_deleted = 1

    return {
        "deleted": True,
        "decision_number": row["decision_number"],
        "docs_deleted": docs_deleted,
    }


async def get_style_corpus_row(corpus_id: UUID) -> dict | None:
    """Return a single style_corpus row by id, or None if missing."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """
            SELECT id, document_id, decision_number, decision_date,
                   subject_categories, full_text, summary, outcome,
                   key_principles, practice_area, appeal_subtype, created_at
            FROM style_corpus WHERE id = $1
            """,
            corpus_id,
        )
    return dict(row) if row else None


async def update_style_corpus_metadata(
    corpus_id: UUID,
    *,
    summary: str | None = None,
    outcome: str | None = None,
    key_principles: list[str] | None = None,
    appeal_subtype: str | None = None,
    practice_area: str | None = None,
    overwrite: bool = False,
) -> dict:
    """Patch the enriched-metadata columns of a style_corpus row.

    By default, only empty columns are filled — passing ``overwrite=True``
    is the caller's signal that they intentionally want to replace existing
    values (used by the re-extract flow when the chair runs it manually).
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        existing = await conn.fetchrow(
            "SELECT summary, outcome, key_principles, appeal_subtype, practice_area "
            "FROM style_corpus WHERE id = $1",
            corpus_id,
        )
        if not existing:
            return {"updated": False, "reason": "not found"}

        sets: dict = {}
        if summary is not None and (overwrite or not (existing["summary"] or "").strip()):
            sets["summary"] = summary
        if outcome is not None and (overwrite or not (existing["outcome"] or "").strip()):
            sets["outcome"] = outcome
        if key_principles is not None:
            current = existing["key_principles"]
            if isinstance(current, str):
                try:
                    current = json.loads(current)
                except json.JSONDecodeError:
                    current = []
            if overwrite or not (current or []):
                sets["key_principles"] = json.dumps(key_principles)
        if appeal_subtype is not None and (overwrite or not (existing["appeal_subtype"] or "").strip()):
            sets["appeal_subtype"] = appeal_subtype
        if practice_area is not None and (overwrite or not (existing["practice_area"] or "").strip()):
            sets["practice_area"] = practice_area

        if not sets:
            return {"updated": False, "reason": "nothing to update", "fields": []}

        cols = list(sets.keys())
        set_clause = ", ".join(f"{c} = ${i + 2}" for i, c in enumerate(cols))
        values = [sets[c] for c in cols]
        await conn.execute(
            f"UPDATE style_corpus SET {set_clause} WHERE id = $1",
            corpus_id, *values,
        )
        return {"updated": True, "fields": cols}


# ── decision_lessons (per-corpus row notes) ────────────────────────


async def list_decision_lessons(corpus_id: UUID) -> list[dict]:
    pool = await get_pool()
    async with pool.acquire() as conn:
        # applied_to_skill column retained for back-compat but no longer selected
        # (LRN-1/INV-IA3: the informative-only flag was removed from the surface;
        #  review_status is the single writer gate).
        rows = await conn.fetch(
            "SELECT id, style_corpus_id, lesson_text, category, source, "
            "       review_status, created_by, created_at, updated_at, synthesized_from "
            "FROM decision_lessons WHERE style_corpus_id = $1 "
            "ORDER BY created_at DESC",
            corpus_id,
        )
    return [dict(r) for r in rows]


async def add_decision_lesson(
    corpus_id: UUID,
    *,
    lesson_text: str,
    category: str = "general",
    source: str = "manual",
    created_by: str = "chaim",
    review_status: str = "proposed",
) -> dict:
    # review_status gate (INV-LRN1/G10): panel-written lessons default to 'proposed'
    # and do NOT flow to the writer until the chair approves. A lesson the chair
    # authors directly is passed review_status='approved' by the caller.
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "INSERT INTO decision_lessons "
            "(style_corpus_id, lesson_text, category, source, created_by, review_status) "
            "VALUES ($1, $2, $3, $4, $5, $6) "
            "RETURNING id, style_corpus_id, lesson_text, category, source, "
            "          review_status, created_by, created_at, updated_at",
            corpus_id, lesson_text, category, source, created_by, review_status,
        )
    return dict(row) if row else {}


async def get_style_corpus_id_by_decision(decision_number: str) -> UUID | None:
    """Resolve the style_corpus row id for a final decision by its number.

    The curator knows the case_number; lessons attach to the corpus row the
    learning pipeline enrolled (enroll_style_corpus). Returns None if the final
    has not been enrolled yet (caller surfaces it — no silent attach).
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT id FROM style_corpus WHERE decision_number = $1 LIMIT 1",
            decision_number,
        )
    return row["id"] if row else None


# ── decision_lessons synthesis (#158 / INV-LRN8) ───────────────────
# Consolidate overlapping lessons into one richer 'synthesis' row; sources are
# marked 'superseded' (provenance, not writer-fed). Mirrors V41 on the same table.

async def fetch_synthesis_candidates(practice_area: str, category: str) -> list[dict]:
    """Lessons eligible for synthesis in one (practice_area, category) shard.

    Only ``review_status='approved'`` lessons that are themselves NOT a synthesis
    output and NOT already superseded — i.e. live, writer-fed lessons. Returns id,
    lesson_text, review_status, source, style_corpus_id and the stored embedding
    (python list or None; the caller lazily backfills NULLs via set_lesson_embedding).
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT dl.id, dl.lesson_text, dl.review_status, dl.source, "
            "       dl.style_corpus_id, dl.embedding "
            "FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
            "WHERE dl.review_status = 'approved' "
            "  AND dl.source <> 'synthesis' "
            "  AND dl.category = $1 "
            "  AND ($2 = '' OR sc.practice_area = $2) "
            "ORDER BY dl.created_at",
            category, practice_area,
        )
    return [dict(r) for r in rows]


async def synthesis_shards(min_size: int = 2) -> list[dict]:
    """(practice_area, category) shards that have ≥min_size live approved lessons —
    the candidate shards a synthesis pass should consider. Largest first."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT sc.practice_area AS practice_area, dl.category AS category, count(*) AS n "
        "FROM decision_lessons dl JOIN style_corpus sc ON sc.id = dl.style_corpus_id "
        "WHERE dl.review_status = 'approved' AND dl.source <> 'synthesis' "
        "GROUP BY 1, 2 HAVING count(*) >= $1 ORDER BY count(*) DESC",
        min_size,
    )
    return [dict(r) for r in rows]


async def set_lesson_embedding(lesson_id: UUID, embedding: list[float]) -> None:
    """Lazy backfill: store a lesson's embedding so clustering/drift don't re-embed."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE decision_lessons SET embedding = $2 WHERE id = $1",
            lesson_id, embedding,
        )


async def nearest_synthesis_lesson(
    vec: list[float], category: str, threshold: float,
) -> "tuple[str, float] | None":
    """Nearest existing synthesis lesson (same category) by cosine, for idempotency —
    so a re-run doesn't create a near-duplicate super-lesson. None if below threshold."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text AS id, 1 - (embedding <=> $1) AS sim "
        "FROM decision_lessons "
        "WHERE source = 'synthesis' AND category = $2 AND embedding IS NOT NULL "
        "  AND review_status <> 'rejected' "
        "ORDER BY embedding <=> $1 LIMIT 1",
        vec, category,
    )
    if not row:
        return None
    sim = float(row["sim"])
    return (row["id"], sim) if sim >= threshold else None


async def apply_lesson_synthesis(
    *,
    corpus_id: UUID,
    lesson_text: str,
    category: str,
    embedding: list[float],
    source_ids: list[UUID],
    review_status: str,
) -> dict:
    """Atomically commit a synthesis: insert the super-lesson (source='synthesis')
    and flip its source lessons to 'superseded' (provenance, no longer writer-fed).
    Returns the new row. INV-LRN8 / G2 — single write path."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                "INSERT INTO decision_lessons "
                "(style_corpus_id, lesson_text, category, source, created_by, "
                " review_status, embedding, synthesized_from) "
                "VALUES ($1, $2, $3, 'synthesis', 'synthesis', $4, $5, $6) "
                "RETURNING id, style_corpus_id, lesson_text, category, source, "
                "          review_status, created_by, created_at, updated_at",
                corpus_id, lesson_text, category, review_status, embedding, source_ids,
            )
            await conn.execute(
                "UPDATE decision_lessons SET review_status = 'superseded', updated_at = now() "
                "WHERE id = ANY($1::uuid[])",
                source_ids,
            )
    return dict(row) if row else {}


async def revert_lesson_synthesis(synthesis_id: UUID) -> dict:
    """Chair veto of a super-lesson: mark it 'rejected' and restore its source lessons
    to 'approved' (so they flow to the writer again). Idempotent; returns counts."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                "SELECT synthesized_from, source FROM decision_lessons WHERE id = $1",
                synthesis_id,
            )
            if not row or row["source"] != "synthesis":
                return {"reverted": False, "reason": "not a synthesis lesson"}
            source_ids = list(row["synthesized_from"] or [])
            await conn.execute(
                "UPDATE decision_lessons SET review_status = 'rejected', updated_at = now() "
                "WHERE id = $1",
                synthesis_id,
            )
            restored = 0
            if source_ids:
                res = await conn.execute(
                    "UPDATE decision_lessons SET review_status = 'approved', updated_at = now() "
                    "WHERE id = ANY($1::uuid[]) AND review_status = 'superseded'",
                    source_ids,
                )
                restored = int(res.split()[-1]) if res.split()[-1].isdigit() else 0
    return {"reverted": True, "restored_sources": restored}


async def update_decision_lesson(
    lesson_id: UUID,
    *,
    lesson_text: str | None = None,
    category: str | None = None,
    review_status: str | None = None,
) -> dict:
    # applied_to_skill removed (LRN-1/INV-IA3): it was an informative-only flag
    # that wrote nowhere; review_status is the single writer gate (INV-LRN1).
    sets: dict = {}
    if lesson_text is not None:
        sets["lesson_text"] = lesson_text
    if category is not None:
        sets["category"] = category
    if review_status is not None:
        sets["review_status"] = review_status
    if not sets:
        return {"updated": False, "reason": "nothing to update"}
    sets["updated_at"] = "now()"  # sentinel — replaced inline below
    cols = [c for c in sets if c != "updated_at"]
    set_clause = ", ".join(f"{c} = ${i + 2}" for i, c in enumerate(cols))
    set_clause += ", updated_at = now()"
    values = [sets[c] for c in cols]
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 "
            f"RETURNING id, style_corpus_id, lesson_text, category, source, "
            f"          review_status, updated_at",
            lesson_id, *values,
        )
    if not row:
        return {"updated": False, "reason": "not found"}
    return {"updated": True, **dict(row)}


async def delete_decision_lesson(lesson_id: UUID) -> dict:
    pool = await get_pool()
    async with pool.acquire() as conn:
        result = await conn.execute(
            "DELETE FROM decision_lessons WHERE id = $1", lesson_id,
        )
    # asyncpg returns "DELETE n"
    deleted = result.split(" ", 1)[1].strip() if " " in result else "0"
    return {"deleted": deleted != "0"}


async def count_decision_lessons_per_corpus() -> dict[str, int]:
    """Map style_corpus.id (str) → lesson count, for badge display in the list."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT style_corpus_id, count(*) AS n "
            "FROM decision_lessons GROUP BY style_corpus_id"
        )
    return {str(r["style_corpus_id"]): r["n"] for r in rows}


# ── chat (style agent conversations) ───────────────────────────────


async def create_chat_conversation(
    *,
    title: str = "שיחה חדשה",
    style_corpus_id: UUID | None = None,
    system_prompt_version: str = "v1",
) -> dict:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "INSERT INTO chat_conversations "
            "(title, style_corpus_id, system_prompt_version) "
            "VALUES ($1, $2, $3) "
            "RETURNING id, title, style_corpus_id, claude_session_id, "
            "          system_prompt_version, created_at, last_message_at",
            title, style_corpus_id, system_prompt_version,
        )
    return dict(row) if row else {}


async def list_chat_conversations(limit: int = 50) -> list[dict]:
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """
            SELECT c.id, c.title, c.style_corpus_id, c.claude_session_id,
                   c.created_at, c.last_message_at,
                   sc.decision_number,
                   (SELECT count(*) FROM chat_messages m WHERE m.conversation_id = c.id) AS message_count
            FROM chat_conversations c
            LEFT JOIN style_corpus sc ON sc.id = c.style_corpus_id
            ORDER BY c.last_message_at DESC NULLS LAST
            LIMIT $1
            """,
            limit,
        )
    return [dict(r) for r in rows]


async def get_chat_conversation(conv_id: UUID) -> dict | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT id, title, style_corpus_id, claude_session_id, "
            "       system_prompt_version, created_at, last_message_at "
            "FROM chat_conversations WHERE id = $1",
            conv_id,
        )
    return dict(row) if row else None


async def delete_chat_conversation(conv_id: UUID) -> dict:
    pool = await get_pool()
    async with pool.acquire() as conn:
        result = await conn.execute(
            "DELETE FROM chat_conversations WHERE id = $1", conv_id,
        )
    deleted = result.split(" ", 1)[1].strip() if " " in result else "0"
    return {"deleted": deleted != "0"}


async def update_chat_conversation_session_id(
    conv_id: UUID, claude_session_id: str,
) -> None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE chat_conversations SET claude_session_id = $1, "
            "       last_message_at = now() "
            "WHERE id = $2",
            claude_session_id, conv_id,
        )


async def add_chat_message(
    conv_id: UUID,
    *,
    role: str,
    content: str,
    raw_events: list | None = None,
) -> dict:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "INSERT INTO chat_messages "
            "(conversation_id, role, content, raw_events) "
            "VALUES ($1, $2, $3, $4) "
            "RETURNING id, conversation_id, role, content, created_at",
            conv_id, role, content, json.dumps(raw_events or []),
        )
        await conn.execute(
            "UPDATE chat_conversations SET last_message_at = now() WHERE id = $1",
            conv_id,
        )
    return dict(row) if row else {}


async def list_chat_messages(conv_id: UUID) -> list[dict]:
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT id, role, content, created_at "
            "FROM chat_messages WHERE conversation_id = $1 "
            "ORDER BY created_at ASC",
            conv_id,
        )
    return [dict(r) for r in rows]


async def get_style_patterns(pattern_type: str | None = None) -> list[dict]:
    pool = await get_pool()
    async with pool.acquire() as conn:
        if pattern_type:
            rows = await conn.fetch(
                "SELECT * FROM style_patterns WHERE pattern_type = $1 ORDER BY frequency DESC",
                pattern_type,
            )
        else:
            rows = await conn.fetch(
                "SELECT * FROM style_patterns ORDER BY pattern_type, frequency DESC"
            )
    return [dict(r) for r in rows]


async def append_global_rule(
    category: str, key: str, items: list[str], seed_if_missing: list | None = None,
) -> int:
    """Append items to a _global appeal_type_rules list value (the writer-consumed
    methodology channel), idempotently and under a row lock. Returns how many NEW
    items were added (existing duplicates are skipped). Single locked read-modify-
    write (MET-2/3) so concurrent appends can't drop items. Shared by the /training
    promote gate (web `_append_methodology_override`) and chair-feedback auto-flow
    (G2 — one append implementation)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            row = await conn.fetchrow(
                "SELECT rule_value FROM appeal_type_rules "
                "WHERE appeal_type = '_global' AND rule_category = $1 AND rule_key = $2 "
                "FOR UPDATE",
                category, key,
            )
            if row:
                current = row["rule_value"]
                if isinstance(current, str):
                    try:
                        current = json.loads(current)
                    except (json.JSONDecodeError, TypeError):
                        current = []
            else:
                current = list(seed_if_missing or [])
            if not isinstance(current, list):
                current = []
            added = [s for s in items if s and s not in current]
            if not added and row:
                return 0
            merged = current + added
            await conn.execute(
                "INSERT INTO appeal_type_rules (id, appeal_type, rule_category, rule_key, rule_value) "
                "VALUES (gen_random_uuid(), '_global', $1, $2, $3::text::jsonb) "
                "ON CONFLICT (appeal_type, rule_category, rule_key) DO UPDATE SET rule_value = $3::text::jsonb",
                category, key, json.dumps(merged, ensure_ascii=False),
            )
            return len(added)


async def get_methodology_overrides(category: str) -> dict:
    """Chair's /methodology edits for one category (golden_ratios / discussion_rules /
    content_checklists). Returns {rule_key: parsed_value}. These OVERRIDE the hardcoded
    lessons.py defaults — the writer must consume them (T15 / INV-LRN4). Mirrors the merge
    in GET /api/methodology/{category}."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT rule_key, rule_value FROM appeal_type_rules "
            "WHERE appeal_type = '_global' AND rule_category = $1",
            category,
        )
    out: dict = {}
    for r in rows:
        raw = r["rule_value"]
        if isinstance(raw, str):
            try:
                raw = json.loads(raw)
            except (json.JSONDecodeError, TypeError):
                pass
        out[r["rule_key"]] = raw
    return out


async def voice_lesson_pool_sizes() -> dict:
    """How many folded voice-learning items are in the writer-consumed pool right now
    (universal discussion_rules + transition_phrases). Used by the prospective held-out
    snapshot to record how much learning had accumulated when a draft was produced."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT rule_category, COALESCE(jsonb_array_length(rule_value), 0) AS n "
            "FROM appeal_type_rules "
            "WHERE appeal_type = '_global' AND rule_key = 'universal' "
            "AND rule_category IN ('discussion_rules', 'transition_phrases')",
        )
    out = {"discussion_rules": 0, "transition_phrases": 0}
    for r in rows:
        out[r["rule_category"]] = r["n"]
    return out


async def record_style_distance_snapshot(
    case_number: str, pair_id: str | None, pool_rules: int, pool_phrases: int,
    anti_pattern_total: int | None, ratio_max_deviation: float | None,
    change_percent: float | None,
) -> None:
    """Append one prospective held-out datapoint (Path A). Append-only; never updates."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "INSERT INTO style_distance_history (case_number, pair_id, "
            "pool_discussion_rules, pool_transition_phrases, anti_pattern_total, "
            "ratio_max_deviation, change_percent) VALUES ($1, $2, $3, $4, $5, $6, $7)",
            case_number, UUID(pair_id) if pair_id else None,
            pool_rules, pool_phrases, anti_pattern_total,
            ratio_max_deviation, change_percent,
        )


async def get_style_distance_history() -> list[dict]:
    """The prospective held-out trend, oldest-first (Path A). Each row = one final
    upload's style-distance measured before that case's lessons were folded."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT case_number, measured_at, pool_discussion_rules, "
            "pool_transition_phrases, anti_pattern_total, ratio_max_deviation, "
            "change_percent FROM style_distance_history ORDER BY measured_at",
        )
    return [
        {
            "case_number": r["case_number"],
            "measured_at": r["measured_at"].isoformat() if r["measured_at"] else None,
            "pool_discussion_rules": r["pool_discussion_rules"],
            "pool_transition_phrases": r["pool_transition_phrases"],
            "anti_pattern_total": r["anti_pattern_total"],
            "ratio_max_deviation": r["ratio_max_deviation"],
            "change_percent": r["change_percent"],
        }
        for r in rows
    ]


async def get_recent_decision_lessons(limit: int = 60, practice_area: str = "") -> list[dict]:
    """Per-decision learnings the chair/curator attached in /training (decision_lessons),
    so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.

    Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved');
    superseded sources (merged by synthesis, #158) are excluded automatically.

    Cap (#157/#158): callers pass config.WRITER_LESSONS_LIMIT (60). Raised from 15 once
    lessons became deduplicated (synthesis) AND chair-approved — the old cap silently
    truncated distinct, vetted style guidance. If even 60 is exceeded the overflow is
    dropped (ORDER BY created_at DESC) and logged (no silent cap, חוקה §6); that WARN is
    the signal to run synthesis (lesson_synthesize_pending) or raise WRITER_LESSONS_LIMIT.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """SELECT dl.lesson_text, dl.category, dl.source,
                      sc.decision_number, sc.practice_area
               FROM decision_lessons dl
               JOIN style_corpus sc ON sc.id = dl.style_corpus_id
               WHERE dl.review_status = 'approved'
                 AND ($2 = '' OR sc.practice_area = $2)
               ORDER BY dl.created_at DESC
               LIMIT $1""",
            limit, practice_area,
        )
        if len(rows) >= limit:
            total = await conn.fetchval(
                """SELECT count(*) FROM decision_lessons dl
                   JOIN style_corpus sc ON sc.id = dl.style_corpus_id
                   WHERE dl.review_status = 'approved'
                     AND ($1 = '' OR sc.practice_area = $1)""",
                practice_area,
            ) or 0
            if total > limit:
                logger.warning(
                    "get_recent_decision_lessons: capped %d→%d approved lessons "
                    "(practice_area=%r) — %d not reaching the writer. Run synthesis "
                    "(lesson_synthesize_pending) or raise WRITER_LESSONS_LIMIT.",
                    total, limit, practice_area or "*", total - limit,
                )
    return [dict(r) for r in rows]


async def create_draft_final_pair(case_id: UUID, draft_text: str, final_path: str = "") -> str:
    """Capture the draft↔final pairing at mark-final (T5 / INV-LRN4). Immutable draft
    snapshot; final_text/diff_stats/analysis filled later by the curator distillation."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """INSERT INTO draft_final_pairs (case_id, draft_text, final_path, status)
               VALUES ($1, $2, $3, 'final_received') RETURNING id""",
            case_id, draft_text, final_path,
        )
    return str(row["id"])


async def update_draft_final_pair(
    pair_id: UUID,
    final_text: str | None = None,
    diff_stats: dict | None = None,
    analysis: dict | None = None,
    status: str | None = None,
) -> None:
    """Advance a pairing row (curator distillation): final_text → diff_stats → analysis → status."""
    sets, params, idx = [], [], 1
    if final_text is not None:
        sets.append(f"final_text = ${idx}"); params.append(final_text); idx += 1
    if diff_stats is not None:
        sets.append(f"diff_stats = ${idx}::jsonb"); params.append(json.dumps(diff_stats, ensure_ascii=False)); idx += 1
    if analysis is not None:
        sets.append(f"analysis = ${idx}::jsonb"); params.append(json.dumps(analysis, ensure_ascii=False)); idx += 1
    if status is not None:
        sets.append(f"status = ${idx}"); params.append(status); idx += 1
    if not sets:
        return
    sets.append("updated_at = now()")
    params.append(pair_id)
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            f"UPDATE draft_final_pairs SET {', '.join(sets)} WHERE id = ${idx}", *params,
        )


async def set_learning_run_outcome(
    case_id: UUID,
    status: str,
    error: str = "",
    steps: dict | None = None,
) -> bool:
    """Stamp the voice-learning pipeline's RUN OUTCOME on the case's latest pair (SCHEMA_V36).

    The pair's `status` says how far the distillation advanced; this says whether the
    run-learning pipeline (scripts/final_learning_pipeline.py) completed or crashed —
    so the case can show 'succeeded / failed + reason'. CAPTURE field on the INV-LRN4
    ledger (not a parallel path). Returns False if no pair exists yet (nothing to stamp).
    """
    payload = {
        "status": status,                       # 'succeeded' | 'failed'
        "error": error or "",
        "steps": steps or {},
    }
    pool = await get_pool()
    async with pool.acquire() as conn:
        # at = DB now() (no clock in the script path); written via the SQL expression.
        row = await conn.fetchrow(
            """UPDATE draft_final_pairs
                   SET learning_run = ($2::jsonb || jsonb_build_object('at', now())),
                       updated_at = now()
                 WHERE id = (
                     SELECT id FROM draft_final_pairs
                      WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1
                 )
             RETURNING id""",
            UUID(str(case_id)), json.dumps(payload, ensure_ascii=False),
        )
    return row is not None


def _as_obj(value) -> dict:
    """JSONB columns come back as text (no codec registered) — parse defensively."""
    if isinstance(value, dict):
        return value
    if isinstance(value, str) and value:
        try:
            parsed = json.loads(value)
            return parsed if isinstance(parsed, dict) else {}
        except (ValueError, TypeError):
            return {}
    return {}


# Halacha-extraction status values that mean the extractor ran and could not produce
# a usable result — surfaced (not swallowed) so the case shows WHY it didn't complete.
_HALACHA_FAIL_STATUSES = {"failed", "partial", "extraction_failed", "no_chunks"}


async def case_learning_status(case: dict) -> dict:
    """Derived (read-only) status of the two post-final pipelines for one case.

    Single source of truth — both MCP `case_get` and GET /api/cases/{case}/learning-status
    call this; no parallel logic. Derives from EXISTING tables (draft_final_pairs,
    style_corpus, decision_lessons, case_law, halachot); the only persisted addition is
    draft_final_pairs.learning_run (SCHEMA_V36), the voice pipeline's explicit outcome.
    """
    case_id = UUID(str(case["id"]))
    case_number = case.get("case_number", "")
    pool = await get_pool()
    async with pool.acquire() as conn:
        pair = await conn.fetchrow(
            """SELECT status, analysis, learning_run, updated_at
                 FROM draft_final_pairs
                WHERE case_id = $1 ORDER BY created_at DESC LIMIT 1""",
            case_id,
        )
        corpus = await conn.fetchrow(
            """SELECT id FROM style_corpus WHERE decision_number = $1 LIMIT 1""",
            case_number,
        )
        lessons_proposed = await conn.fetchval(
            """SELECT count(*) FROM decision_lessons dl
                 JOIN style_corpus sc ON sc.id = dl.style_corpus_id
                WHERE sc.decision_number = $1""",
            case_number,
        )
        law = await conn.fetchrow(
            """SELECT id, halacha_extraction_status FROM case_law
                WHERE case_number = $1 AND source_kind = 'internal_committee'
                ORDER BY created_at DESC LIMIT 1""",
            case_number,
        )
        halacha_counts = {"total": 0, "approved": 0, "pending": 0, "rejected": 0}
        if law:
            crow = await conn.fetchrow(
                """SELECT count(*) AS total,
                          count(*) FILTER (WHERE review_status = 'approved')      AS approved,
                          count(*) FILTER (WHERE review_status = 'pending_review') AS pending,
                          count(*) FILTER (WHERE review_status = 'rejected')      AS rejected
                     FROM halachot WHERE case_law_id = $1""",
                law["id"],
            )
            halacha_counts = dict(crow)

    # ── Voice learning ──────────────────────────────────────────────────
    pair_status = pair["status"] if pair else ""
    run = _as_obj(pair["learning_run"]) if pair else {}
    if run:
        outcome = run.get("status") or "not_run"      # explicit pipeline outcome (V36)
        run_error = run.get("error") or ""
    elif pair_status in ("analyzed", "lessons_folded"):
        outcome = "succeeded"                          # distillation done (pre-V36 / direct ingest)
        run_error = ""
    else:
        outcome = "not_run"
        run_error = ""
    analysis = _as_obj(pair["analysis"]) if pair else {}
    voice = {
        "ran": bool(run),
        "outcome": outcome,                            # succeeded | failed | not_run
        "error": run_error or None,
        "pair_status": pair_status,
        "lessons_count": len(analysis.get("changes", []) or []),
        "style_corpus_enrolled": corpus is not None,
        "lessons_proposed": int(lessons_proposed or 0),
        "analyzed_at": pair["updated_at"].isoformat() if pair and pair["updated_at"] else None,
    }

    # ── Halacha extraction ──────────────────────────────────────────────
    if not law:
        halacha = {
            "enrolled_in_corpus": False,
            "not_enrolled_reason": "ההחלטה הסופית טרם נכנסה לקורפוס-הפסיקה הפנימי — אין ממה לחלץ הלכות",
            "status": None,
            "halachot_count": 0,
            "approved": 0, "pending": 0, "rejected": 0,
        }
    else:
        hstatus = law["halacha_extraction_status"] or "pending"
        halacha = {
            "enrolled_in_corpus": True,
            "not_enrolled_reason": None,
            "status": hstatus,
            "failed": hstatus in _HALACHA_FAIL_STATUSES,
            "halachot_count": int(halacha_counts.get("total", 0) or 0),
            "approved": int(halacha_counts.get("approved", 0) or 0),
            "pending": int(halacha_counts.get("pending", 0) or 0),
            "rejected": int(halacha_counts.get("rejected", 0) or 0),
        }

    return {
        "final_uploaded": pair is not None,
        "voice_learning": voice,
        "halacha_extraction": halacha,
    }


async def list_draft_final_pairs(status: str | None = None, limit: int = 200) -> list[dict]:
    """Reconciliation ledger: all decisions paired with their final + status."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        if status:
            rows = await conn.fetch(
                """SELECT p.id, p.case_id, c.case_number, c.title, p.status,
                          p.diff_stats, p.created_at, p.updated_at
                   FROM draft_final_pairs p LEFT JOIN cases c ON c.id = p.case_id
                   WHERE p.status = $1 ORDER BY p.created_at DESC LIMIT $2""",
                status, limit,
            )
        else:
            rows = await conn.fetch(
                """SELECT p.id, p.case_id, c.case_number, c.title, p.status,
                          p.diff_stats, p.created_at, p.updated_at
                   FROM draft_final_pairs p LEFT JOIN cases c ON c.id = p.case_id
                   ORDER BY p.created_at DESC LIMIT $1""",
                limit,
            )
    return [dict(r) for r in rows]


async def get_draft_final_pair(pair_id: UUID) -> dict | None:
    """Full pairing row incl. analysis (curator proposal) — for the T14 approval gate."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """SELECT p.id, p.case_id, c.case_number, c.title, p.status,
                      p.draft_text, p.final_text, p.diff_stats, p.analysis,
                      p.created_at, p.updated_at
               FROM draft_final_pairs p LEFT JOIN cases c ON c.id = p.case_id
               WHERE p.id = $1""",
            pair_id,
        )
    return dict(row) if row else None


async def insert_style_exemplar(
    decision_number: str, source: str, practice_area: str, outcome: str,
    section: str, paragraph_text: str, word_count: int, embedding: list[float],
) -> None:
    """Insert one block-level style exemplar (T1 backfill)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            """INSERT INTO style_exemplars
                   (decision_number, source, practice_area, outcome, section,
                    paragraph_text, word_count, embedding)
               VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""",
            decision_number, source, practice_area, outcome, section,
            paragraph_text, word_count, embedding,
        )


async def delete_style_exemplars(decision_number: str, source: str) -> int:
    """Idempotent backfill: clear a decision's exemplars before re-inserting."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        res = await conn.execute(
            "DELETE FROM style_exemplars WHERE decision_number = $1 AND source = $2",
            decision_number, source,
        )
    try:
        return int(res.split()[-1])
    except (ValueError, IndexError):
        return 0


async def search_style_exemplars(
    query_embedding: list[float],
    section: str | None = None,
    outcome: str | None = None,
    practice_area: str | None = None,
    limit: int = 6,
) -> list[dict]:
    """Retrieve Dafna's own block-level paragraphs as STYLE exemplars (T2).
    Filters by section (block) + optionally outcome/practice_area for the closest
    match to the block being written. Soft filters: outcome/practice_area narrow but
    never zero-out — section is the hard filter."""
    pool = await get_pool()
    conditions, params, idx = [], [query_embedding, limit], 3
    if section:
        conditions.append(f"section = ${idx}"); params.append(section); idx += 1
    if outcome:
        conditions.append(f"(outcome = ${idx} OR outcome = '')"); params.append(outcome); idx += 1
    if practice_area:
        conditions.append(f"(practice_area = ${idx} OR practice_area = '')"); params.append(practice_area); idx += 1
    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    sql = f"""
        SELECT decision_number, source, section, outcome, practice_area,
               paragraph_text, word_count,
               1 - (embedding <=> $1) AS score
        FROM style_exemplars
        {where}
        ORDER BY embedding <=> $1
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *params)
    return [dict(r) for r in rows]


async def count_style_exemplars() -> dict:
    """Coverage check for the backfill."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        total = await conn.fetchval("SELECT count(*) FROM style_exemplars")
        by_section = await conn.fetch(
            "SELECT section, count(*) AS n FROM style_exemplars GROUP BY section ORDER BY n DESC"
        )
        decisions = await conn.fetchval(
            "SELECT count(DISTINCT decision_number) FROM style_exemplars"
        )
    return {"total": total, "decisions": decisions, "by_section": [dict(r) for r in by_section]}


async def upsert_style_pattern(
    pattern_type: str,
    pattern_text: str,
    context: str = "",
    examples: list[str] | None = None,
    appeal_subtype: str = "",
) -> None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        existing = await conn.fetchrow(
            "SELECT id, frequency FROM style_patterns "
            "WHERE pattern_type = $1 AND pattern_text = $2 AND appeal_subtype = $3",
            pattern_type, pattern_text, appeal_subtype,
        )
        if existing:
            await conn.execute(
                "UPDATE style_patterns SET frequency = frequency + 1 WHERE id = $1",
                existing["id"],
            )
        else:
            await conn.execute(
                """INSERT INTO style_patterns (pattern_type, pattern_text, context, examples, appeal_subtype)
                   VALUES ($1, $2, $3, $4, $5)""",
                pattern_type, pattern_text, context,
                json.dumps(examples or []),
                appeal_subtype,
            )


async def clear_style_patterns(appeal_subtype: str = "") -> None:
    """Delete style patterns, optionally filtered by appeal_subtype.

    Empty appeal_subtype = delete ALL patterns.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        if appeal_subtype:
            await conn.execute(
                "DELETE FROM style_patterns WHERE appeal_subtype = $1", appeal_subtype
            )
        else:
            await conn.execute("DELETE FROM style_patterns")


# ── Semantic Search (V2 — decision blocks & case law) ─────────────

async def search_similar_paragraphs(
    query_embedding: list[float],
    limit: int = 10,
    block_type: str | None = None,
) -> list[dict]:
    """Search decision paragraphs by semantic similarity."""
    pool = await get_pool()
    conditions = []
    params: list = [query_embedding, limit]
    param_idx = 3

    if block_type:
        conditions.append(f"db.block_id = ${param_idx}")
        params.append(block_type)
        param_idx += 1

    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""

    sql = f"""
        SELECT dp.content, dp.word_count, dp.paragraph_number,
               db.block_id AS block_type, db.title AS block_title,
               c.case_number, c.title AS case_title,
               d.outcome, d.author,
               1 - (pe.embedding <=> $1) AS score
        FROM paragraph_embeddings pe
        JOIN decision_paragraphs dp ON dp.id = pe.paragraph_id
        JOIN decision_blocks db ON db.id = dp.block_id
        JOIN decisions d ON d.id = db.decision_id
        JOIN cases c ON c.id = d.case_id
        {where}
        ORDER BY pe.embedding <=> $1
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *params)
    return [dict(r) for r in rows]


async def search_similar_case_law(
    query_embedding: list[float],
    limit: int = 5,
) -> list[dict]:
    """Search case law by semantic similarity."""
    pool = await get_pool()
    sql = """
        SELECT cl.case_number, cl.case_name, cl.court, cl.summary,
               cl.key_quote, cl.subject_tags,
               cle.chunk_text,
               1 - (cle.embedding <=> $1) AS score
        FROM case_law_embeddings cle
        JOIN case_law cl ON cl.id = cle.case_law_id
        ORDER BY cle.embedding <=> $1
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, query_embedding, limit)
    results = []
    for r in rows:
        d = dict(r)
        if isinstance(d.get("subject_tags"), str):
            d["subject_tags"] = json.loads(d["subject_tags"])
        results.append(d)
    return results


async def search_precedents(
    query_embedding: list[float],
    limit: int = 10,
) -> list[dict]:
    """Combined search: paragraphs + case law, ranked by score."""
    paragraphs = await search_similar_paragraphs(query_embedding, limit=limit)
    case_law = await search_similar_case_law(query_embedding, limit=limit)

    # Combine and sort by score
    results = []
    for p in paragraphs:
        results.append({
            "type": "decision_paragraph",
            "score": float(p["score"]),
            "case_number": p["case_number"],
            "case_title": p["case_title"],
            "block_type": p["block_type"],
            "content": p["content"][:500],
            "author": p["author"],
        })
    for c in case_law:
        results.append({
            "type": "case_law",
            "score": float(c["score"]),
            "case_number": c["case_number"],
            "case_name": c["case_name"],
            "court": c["court"],
            "content": c["summary"],
        })

    results.sort(key=lambda x: x["score"], reverse=True)
    return results[:limit]


# ── Case precedents (CRUD) ────────────────────────────────────────


async def create_case_precedent(
    case_id: UUID,
    quote: str,
    citation: str,
    section_id: str | None = None,
    chair_note: str = "",
    pdf_document_id: UUID | None = None,
    practice_area: str | None = None,
    argument_id: UUID | None = None,
    case_law_id: UUID | None = None,
    verified: bool = False,
) -> dict:
    """Insert a new precedent attached to a case.

    ``argument_id``/``case_law_id``/``verified`` (X11 #154) link the attachment to
    the specific legal argument it supports and the corpus ruling, and mark whether
    the chair verified it (the INV-AH gate the writer respects)."""
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        INSERT INTO case_precedents
            (case_id, section_id, quote, citation, chair_note, pdf_document_id,
             practice_area, argument_id, case_law_id, verified, verified_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
                CASE WHEN $10 THEN now() ELSE NULL END)
        RETURNING *
        """,
        case_id, section_id, quote, citation, chair_note, pdf_document_id,
        practice_area, argument_id, case_law_id, verified,
    )
    return dict(row)


async def set_case_precedent_verified(
    precedent_id: UUID, verified: bool, chair_note: str | None = None,
) -> dict | None:
    """Toggle the chair-verification gate on an attached precedent (#154), optionally
    updating the chair note. Sets verified_at when verifying, clears it when un-verifying."""
    pool = await get_pool()
    sets = ["verified = $2", "verified_at = CASE WHEN $2 THEN now() ELSE NULL END",
            "updated_at = now()"]
    params: list = [precedent_id, verified]
    if chair_note is not None:
        sets.append(f"chair_note = ${len(params) + 1}")
        params.append(chair_note)
    row = await pool.fetchrow(
        f"UPDATE case_precedents SET {', '.join(sets)} WHERE id = $1 RETURNING *",
        *params,
    )
    return dict(row) if row else None


async def list_case_precedents(case_id: UUID) -> list[dict]:
    """List all precedents attached to a case, ordered by section then creation time."""
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT id, case_id, section_id, quote, citation, chair_note,
               pdf_document_id, practice_area, argument_id, case_law_id,
               verified, verified_at, created_at, updated_at
        FROM case_precedents
        WHERE case_id = $1
        ORDER BY section_id NULLS LAST, created_at
        """,
        case_id,
    )
    return [dict(r) for r in rows]


async def delete_case_precedent(precedent_id: UUID) -> bool:
    """Delete a precedent attachment by ID. Returns True if deleted."""
    pool = await get_pool()
    result = await pool.execute(
        "DELETE FROM case_precedents WHERE id = $1", precedent_id
    )
    return result == "DELETE 1"


async def search_precedent_library(
    query: str, practice_area: str = "", limit: int = 10,
) -> list[dict]:
    """Search all precedents across cases by citation or quote text."""
    pool = await get_pool()
    pattern = f"%{query}%"
    if practice_area:
        rows = await pool.fetch(
            """
            SELECT id, case_id, section_id, quote, citation, chair_note,
                   practice_area, created_at
            FROM case_precedents
            WHERE (citation ILIKE $1 OR quote ILIKE $1)
              AND practice_area = $2
            ORDER BY created_at DESC
            LIMIT $3
            """,
            pattern, practice_area, limit,
        )
    else:
        rows = await pool.fetch(
            """
            SELECT id, case_id, section_id, quote, citation, chair_note,
                   practice_area, created_at
            FROM case_precedents
            WHERE citation ILIKE $1 OR quote ILIKE $1
            ORDER BY created_at DESC
            LIMIT $2
            """,
            pattern, limit,
        )
    return [dict(r) for r in rows]


# ── Chair feedback ────────────────────────────────────────────────

async def record_chair_feedback(
    case_id: UUID | None,
    block_id: str,
    feedback_text: str,
    category: str = "other",
    lesson_extracted: str = "",
) -> UUID:
    """Record feedback from the chair (Dafna) on a draft block."""
    pool = await get_pool()
    feedback_id = uuid4()
    async with pool.acquire() as conn:
        await conn.execute(
            """INSERT INTO chair_feedback
               (id, case_id, block_id, feedback_text, category, lesson_extracted)
               VALUES ($1, $2, $3, $4, $5, $6)""",
            feedback_id, case_id, block_id, feedback_text, category,
            lesson_extracted,
        )
    return feedback_id


async def list_chair_feedback(
    case_id: UUID | None = None,
    category: str | None = None,
    unresolved_only: bool = False,
    limit: int = 100,
) -> list[dict]:
    """List chair feedback, optionally filtered. Capped by limit (INV-TOOL5 / GAP-53)."""
    pool = await get_pool()
    conditions = []
    params: list = []
    idx = 1

    if case_id:
        conditions.append(f"case_id = ${idx}")
        params.append(case_id)
        idx += 1
    if category:
        conditions.append(f"category = ${idx}")
        params.append(category)
        idx += 1
    if unresolved_only:
        conditions.append("resolved = FALSE")

    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    params.append(max(1, int(limit)))
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            f"SELECT * FROM chair_feedback {where} ORDER BY created_at DESC LIMIT ${idx}",
            *params,
        )
    return [dict(r) for r in rows]


async def get_chair_feedback(feedback_id: UUID) -> dict | None:
    """Return a single chair_feedback row by id (with case_number), or None."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """SELECT cf.*, c.case_number, c.appeal_type AS case_appeal_type
               FROM chair_feedback cf
               LEFT JOIN cases c ON c.id = cf.case_id
               WHERE cf.id = $1""",
            feedback_id,
        )
    return dict(row) if row else None


async def resolve_chair_feedback(
    feedback_id: UUID,
    applied_to: list[str],
) -> None:
    """Mark feedback as resolved and record where it was applied."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            """UPDATE chair_feedback
               SET resolved = TRUE, applied_to = $2
               WHERE id = $1""",
            feedback_id, applied_to,
        )


# ── Appraiser facts (V5 — for interim drafts) ─────────────────────

async def replace_appraiser_facts(
    case_id: UUID,
    document_id: UUID,
    facts: list[dict],
) -> int:
    """Replace all appraiser_facts for a given document.

    Each fact dict: appraiser_name, appraiser_side, fact_type ('plan'|'permit'),
    identifier, details (dict), page_number (optional).
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "DELETE FROM appraiser_facts WHERE document_id = $1", document_id,
            )
            for f in facts:
                await conn.execute(
                    """INSERT INTO appraiser_facts
                       (case_id, document_id, appraiser_name, appraiser_side,
                        fact_type, identifier, details, page_number)
                       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""",
                    case_id, document_id,
                    f["appraiser_name"],
                    f.get("appraiser_side", ""),
                    f["fact_type"],
                    f["identifier"],
                    json.dumps(f.get("details", {}), ensure_ascii=False),
                    f.get("page_number"),
                )
    return len(facts)


async def list_appraiser_facts(
    case_id: UUID,
    fact_type: str | None = None,
) -> list[dict]:
    """List appraiser_facts for a case, optionally filtered by fact_type."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        if fact_type:
            rows = await conn.fetch(
                """SELECT * FROM appraiser_facts
                   WHERE case_id = $1 AND fact_type = $2
                   ORDER BY identifier, appraiser_name""",
                case_id, fact_type,
            )
        else:
            rows = await conn.fetch(
                """SELECT * FROM appraiser_facts
                   WHERE case_id = $1
                   ORDER BY fact_type, identifier, appraiser_name""",
                case_id,
            )
    results = []
    for r in rows:
        d = dict(r)
        d["id"] = str(d["id"])
        d["case_id"] = str(d["case_id"])
        d["document_id"] = str(d["document_id"])
        if isinstance(d.get("details"), str):
            d["details"] = json.loads(d["details"])
        results.append(d)
    return results


async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
    """Detect conflicts: identifiers cited by 2+ different appraisers in this case.

    A conflict exists when the SAME identifier (e.g., "תמ"א 38") was reported
    differently by two appraisers — different details, or one cited it and the
    other did not. Returns list of conflict groups. Each entry in a group
    carries the appraiser's side so the caller can label it as committee /
    appellant / deciding.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """SELECT identifier, fact_type,
                      json_agg(jsonb_build_object(
                          'appraiser_name', appraiser_name,
                          'appraiser_side', appraiser_side,
                          'details', details,
                          'page_number', page_number,
                          'document_id', document_id
                      ) ORDER BY
                          CASE appraiser_side
                              WHEN 'committee' THEN 1
                              WHEN 'appellant' THEN 2
                              WHEN 'deciding'  THEN 3
                              ELSE 4
                          END,
                          appraiser_name
                      ) AS entries,
                      COUNT(DISTINCT appraiser_name) AS n_appraisers
               FROM appraiser_facts
               WHERE case_id = $1
               GROUP BY identifier, fact_type
               HAVING COUNT(DISTINCT appraiser_name) > 1""",
            case_id,
        )
    conflicts = []
    for r in rows:
        entries = r["entries"]
        if isinstance(entries, str):
            entries = json.loads(entries)
        # Parse nested details if still strings
        for e in entries:
            if isinstance(e.get("details"), str):
                e["details"] = json.loads(e["details"])
        conflicts.append({
            "identifier": r["identifier"],
            "fact_type": r["fact_type"],
            "n_appraisers": r["n_appraisers"],
            "entries": entries,
        })
    return conflicts


# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────

async def replace_protocol_analysis(
    case_id: UUID,
    document_id: UUID,
    rows: list[dict],
) -> int:
    """Replace all protocol_analysis rows for a given protocol document (idempotent).

    Each row dict: change_type ('dropped'|'strengthened'|'newly_raised'),
    party_role, argument_id (UUID|None), argument_title, summary,
    sharpened_question, evidence_quote, page_number (optional).

    Idempotent on document_id (INV-TOOL3): re-running analyze_protocol replaces
    the prior verdict for that protocol rather than appending duplicates.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "DELETE FROM protocol_analysis WHERE document_id = $1", document_id,
            )
            for r in rows:
                await conn.execute(
                    """INSERT INTO protocol_analysis
                       (case_id, document_id, party_role, change_type, argument_id,
                        argument_title, summary, sharpened_question, evidence_quote, page_number)
                       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)""",
                    case_id, document_id,
                    r.get("party_role", "") or "",
                    r["change_type"],
                    r.get("argument_id"),
                    r.get("argument_title", "") or "",
                    r["summary"],
                    r.get("sharpened_question", "") or "",
                    r.get("evidence_quote", "") or "",
                    r.get("page_number"),
                )
    return len(rows)


async def list_protocol_analysis(
    case_id: UUID,
    change_type: str | None = None,
) -> list[dict]:
    """List protocol_analysis rows for a case, optionally filtered by change_type.

    The read side of the extract/get symmetry (INV-TOOL4) for analyze_protocol.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        if change_type:
            rows = await conn.fetch(
                """SELECT * FROM protocol_analysis
                   WHERE case_id = $1 AND change_type = $2
                   ORDER BY change_type, party_role, created_at""",
                case_id, change_type,
            )
        else:
            rows = await conn.fetch(
                """SELECT * FROM protocol_analysis
                   WHERE case_id = $1
                   ORDER BY change_type, party_role, created_at""",
                case_id,
            )
    results = []
    for r in rows:
        d = dict(r)
        d["id"] = str(d["id"])
        d["case_id"] = str(d["case_id"])
        d["document_id"] = str(d["document_id"])
        if d.get("argument_id") is not None:
            d["argument_id"] = str(d["argument_id"])
        results.append(d)
    return results


# ── Plans registry (V38) ──────────────────────────────────────────
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.

def _normalize_plan_number(raw: str) -> str:
    """Canonical write-time form of a plan identifier (G1/INV-DM2; mirrors X1).

    Deterministic and format-only — does NOT invent or drop data:
      trim · unify Hebrew gershayim (״/‴ → ") · strip a leading bare scheme word
      ("תכנית"/"תוכנית") · collapse whitespace around '/' and runs of spaces.
    NOTE: תמ"א / תב"ע are KEPT — the scheme acronym is part of the identifier; only
    the generic lead word "תכנית"/"תוכנית" is stripped. The full surface form
    (including "תכנית") lives in display_name, never in the key.
    """
    s = (raw or "").strip()
    s = s.replace("״", '"').replace("″", '"')  # gershayim → ASCII "
    s = re.sub(r"^(?:תכנית|תוכנית)\s+", "", s)
    s = re.sub(r"\s*/\s*", "/", s)
    s = " ".join(s.split())
    return s


def _coerce_plan_date(v) -> date | None:
    """Accept a date/datetime/ISO-string/DD.MM.YYYY and return a date (or None).

    The extractors emit ISO (YYYY-MM-DD); DD.MM.YYYY / DD/MM/YYYY is tolerated for
    safety. Unparseable → None (the validity clause is simply omitted — never guessed).
    """
    if not v:
        return None
    if isinstance(v, datetime):
        return v.date()
    if isinstance(v, date):
        return v
    s = str(v).strip()
    if not s:
        return None
    try:
        return date.fromisoformat(s[:10])
    except ValueError:
        pass
    m = re.match(r"^\s*(\d{1,2})[./](\d{1,2})[./](\d{4})\s*$", s)
    if m:
        d, mo, y = (int(g) for g in m.groups())
        try:
            return date(y, mo, d)
        except ValueError:
            return None
    return None


def format_plan_citation(plan: dict) -> str:
    """Render the canonical block-tet citation sentence from a plan record.

    DETERMINISTIC — the identity+validity clause is built from stored fields, never
    by an LLM (INV-AH). Pattern (chair-approved, corpus-derived, רשומות-only):
      {display} פורסמה למתן תוקף ברשומות ביום {D.M.YYYY}[, י"פ {yalkut}][ — {purpose}].
    Returns '' when there is neither a display name nor a plan number.
    """
    name = (plan.get("display_name") or "").strip() or (plan.get("plan_number") or "").strip()
    if not name:
        return ""
    sentence = name
    gd = _coerce_plan_date(plan.get("gazette_date"))
    if gd:
        clause = f"פורסמה למתן תוקף ברשומות ביום {gd.day}.{gd.month}.{gd.year}"
        yalkut = (plan.get("yalkut_number") or "").strip()
        if yalkut:
            clause += f', י"פ {yalkut}'
        sentence = f"{name} {clause}"
    purpose = (plan.get("purpose") or "").strip()
    if purpose:
        sentence += f" — {purpose}"
    if not sentence.endswith("."):
        sentence += "."
    return sentence


# Clean court docket inside a possibly citation-shaped case_number
# ("עע\"מ 683/13" → "683/13"). Legacy court-ruling rows stored the full citation
# in the identity field (X1 §4 known violation); pull the docket out so the
# assembled citation never doubles the prefix.
_CITATION_DOCKET_RE = re.compile(r"\d{1,6}(?:[-/]\d{1,4}){1,2}")

# District → administrative-court abbreviation as it appears in citations
# (`עת"מ (י-ם) 1234/56 ...`). Empty/unknown → the abbrev parenthetical is omitted
# rather than guessed.
_DISTRICT_COURT_ABBREV = {
    "ירושלים": "י-ם",
    "תל אביב": 'ת"א',
    "מרכז": "מרכז",
    "חיפה": "חי'",
    "צפון": "נצ'",
    "דרום": 'ב"ש',
}


def _citation_docket(case_number: str) -> str:
    s = (case_number or "").strip()
    if not s:
        return ""
    m = _CITATION_DOCKET_RE.search(s)
    return m.group(0) if m else s


def format_precedent_citation(
    record: dict, *, parties: str | None = None, court_prefix: str = "",
) -> str:
    """Deterministically render a precedent's unified-rules citation (מראה מקום).

    DERIVED display field (X1 §3 / INV-ID2) assembled from stored components — NEVER
    formatted by an LLM, which proved to drop the field outright (#145). The LLM's job
    shrinks to extracting reliable COMPONENTS (the ``parties`` line and, for court
    rulings, the caption ``court_prefix``); the formatted string is built here.

      • ועדת-ערר family — prefix from ``proceeding_type`` ('ערר'/'בל"מ'), forum from
        ``district``, national level → 'ערר ארצי'. Reporter: our own decisions
        (``source_kind='internal_committee'``) are unpublished → date only; external /
        Nevo rows → 'נבו '.
      • court rulings (עליון/מנהלי) — prefix from the caption (``court_prefix``, e.g.
        'ע"א'/'עת"מ'/'ת"א'); admin-court district abbrev when known; reporter 'נבו '.

    Abstains (returns '') when an essential component is missing — parties, docket, date,
    or an indeterminate court prefix — never inventing one (INV-AH).
    """
    parties = (parties if parties is not None else (record.get("parties") or "")).strip()
    docket = _citation_docket(record.get("case_number") or "")
    d = _coerce_plan_date(record.get("date"))
    if not (parties and docket and d):
        return ""
    date_str = f"{d.day}.{d.month}.{d.year}"

    level = (record.get("precedent_level") or "").strip()
    source_type = (record.get("source_type") or "").strip()
    is_committee = level.startswith("ועדת_ערר") or source_type == "appeals_committee"

    if is_committee:
        reporter = "" if record.get("source_kind") == "internal_committee" else "נבו "
        if level == "ועדת_ערר_ארצית":
            head = f"ערר ארצי {docket}"
        else:
            prefix = 'בל"מ' if (record.get("proceeding_type") or "").strip() == 'בל"מ' else "ערר"
            district = (record.get("district") or "").strip()
            if not district:
                return ""
            head = f"{prefix} (ועדות ערר - מחוז {district}) {docket}"
    else:
        prefix = (court_prefix or "").strip()
        if not prefix:
            return ""  # court-ruling prefix is not derivable from structured fields
        reporter = "נבו "
        if level == "מנהלי":
            abbrev = _DISTRICT_COURT_ABBREV.get((record.get("district") or "").strip(), "")
            head = f"{prefix} ({abbrev}) {docket}" if abbrev else f"{prefix} {docket}"
        else:
            head = f"{prefix} {docket}"

    return f"{head} **{parties}** ({reporter}{date_str})"


def _plan_row_to_dict(row) -> dict | None:
    if row is None:
        return None
    d = dict(row)
    d["id"] = str(d["id"])
    if d.get("source_document_id"):
        d["source_document_id"] = str(d["source_document_id"])
    if isinstance(d.get("discrepancies"), str):
        d["discrepancies"] = json.loads(d["discrepancies"])
    gd = d.get("gazette_date")
    d["gazette_date"] = gd.isoformat() if gd else None
    d["aliases"] = list(d.get("aliases") or [])
    d.pop("meta_tsv", None)
    return d


async def upsert_plan(
    plan_number: str,
    display_name: str = "",
    aliases: list[str] | None = None,
    plan_type: str = "",
    gazette_date=None,
    yalkut_number: str = "",
    purpose: str = "",
    review_status: str = "pending_review",
    source_case_number: str = "",
    source_document_id: UUID | None = None,
    model_used: str = "",
) -> dict:
    """Idempotent upsert of a plan, keyed on the normalized plan_number.

    G3 (idempotent) + G1 (normalize at write). Merge rule (G10 / no-silent-swallow):
      • new plan        → insert as given (LLM extractions arrive 'pending_review').
      • existing APPROVED → NEVER overwrite validity; add unseen aliases and record any
        DIFFERING validity value into `discrepancies` for the chair to adjudicate.
      • existing PENDING/REJECTED → fill empty fields from the new data (merge-up).
    Returns the resulting plan dict.
    """
    if review_status not in ("pending_review", "approved", "rejected"):
        raise ValueError(f"review_status לא חוקי: {review_status}")
    num = _normalize_plan_number(plan_number)
    if not num:
        raise ValueError("plan_number ריק לאחר נרמול")
    gd = _coerce_plan_date(gazette_date)
    display_name = (display_name or "").strip() or num
    incoming_aliases = [a.strip() for a in (aliases or []) if a and a.strip()]

    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            ex = await conn.fetchrow(
                "SELECT * FROM plans WHERE plan_number = $1 FOR UPDATE", num,
            )

            if ex is None:
                merged_aliases = sorted({
                    a for a in incoming_aliases if a not in (num, display_name)
                })
                citation = format_plan_citation({
                    "display_name": display_name, "plan_number": num,
                    "gazette_date": gd, "yalkut_number": yalkut_number, "purpose": purpose,
                })
                row = await conn.fetchrow(
                    """INSERT INTO plans
                       (plan_number, display_name, aliases, plan_type, gazette_date,
                        yalkut_number, purpose, citation_formatted, review_status,
                        source_case_number, source_document_id, model_used)
                       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
                       RETURNING *""",
                    num, display_name, merged_aliases, plan_type or "", gd,
                    yalkut_number or "", purpose or "", citation, review_status,
                    source_case_number or "", source_document_id, model_used or "",
                )
                return _plan_row_to_dict(row)

            ex = dict(ex)
            # Always accumulate newly-seen surface forms.
            seen = set(ex.get("aliases") or [])
            for a in incoming_aliases + [display_name]:
                if a and a not in (num, ex.get("display_name") or ""):
                    seen.add(a)
            merged_aliases = sorted(seen)

            if ex["review_status"] == "approved":
                # Do not touch chair-approved validity. Record any conflicting value.
                disc = ex.get("discrepancies") or []
                if isinstance(disc, str):
                    disc = json.loads(disc)
                checks = (
                    ("gazette_date", gd.isoformat() if gd else "",
                     ex["gazette_date"].isoformat() if ex["gazette_date"] else ""),
                    ("yalkut_number", (yalkut_number or "").strip(), ex["yalkut_number"] or ""),
                    ("purpose", (purpose or "").strip(), ex["purpose"] or ""),
                )
                for field, newval, oldval in checks:
                    if newval and newval != oldval:
                        disc.append({
                            "field": field, "old": oldval, "new": newval,
                            "source_case_number": source_case_number or "",
                        })
                row = await conn.fetchrow(
                    """UPDATE plans SET aliases = $2, discrepancies = $3, updated_at = now()
                       WHERE id = $1 RETURNING *""",
                    ex["id"], merged_aliases,
                    json.dumps(disc, ensure_ascii=False),
                )
                return _plan_row_to_dict(row)

            # pending / rejected → merge-up empty fields, keep existing review_status.
            new_display = ex["display_name"] or display_name
            new_type = ex["plan_type"] or plan_type or ""
            new_gd = ex["gazette_date"] or gd
            new_yalkut = ex["yalkut_number"] or yalkut_number or ""
            new_purpose = ex["purpose"] or purpose or ""
            citation = format_plan_citation({
                "display_name": new_display, "plan_number": num,
                "gazette_date": new_gd, "yalkut_number": new_yalkut, "purpose": new_purpose,
            })
            row = await conn.fetchrow(
                """UPDATE plans SET
                       display_name = $2, aliases = $3, plan_type = $4, gazette_date = $5,
                       yalkut_number = $6, purpose = $7, citation_formatted = $8,
                       source_case_number = COALESCE(NULLIF($9, ''), source_case_number),
                       source_document_id = COALESCE($10, source_document_id),
                       model_used = COALESCE(NULLIF($11, ''), model_used),
                       updated_at = now()
                   WHERE id = $1 RETURNING *""",
                ex["id"], new_display, merged_aliases, new_type, new_gd,
                new_yalkut, new_purpose, citation,
                source_case_number or "", source_document_id, model_used or "",
            )
            return _plan_row_to_dict(row)


async def get_plan_by_number(plan_number: str) -> dict | None:
    """Look up a plan by normalized number; falls back to an alias match."""
    num = _normalize_plan_number(plan_number)
    if not num:
        return None
    pool = await get_pool()
    row = await pool.fetchrow("SELECT * FROM plans WHERE plan_number = $1", num)
    if row is None:
        row = await pool.fetchrow("SELECT * FROM plans WHERE $1 = ANY(aliases)", num)
    return _plan_row_to_dict(row)


async def get_plan_by_id(plan_id: UUID) -> dict | None:
    pool = await get_pool()
    row = await pool.fetchrow("SELECT * FROM plans WHERE id = $1", plan_id)
    return _plan_row_to_dict(row)


async def list_plans(review_status: str = "", limit: int = 500) -> list[dict]:
    """List plans, optionally filtered by review_status (backlog view)."""
    pool = await get_pool()
    if review_status:
        rows = await pool.fetch(
            "SELECT * FROM plans WHERE review_status = $1 ORDER BY updated_at DESC LIMIT $2",
            review_status, limit,
        )
    else:
        rows = await pool.fetch(
            "SELECT * FROM plans ORDER BY review_status, updated_at DESC LIMIT $1", limit,
        )
    return [_plan_row_to_dict(r) for r in rows]


async def search_plans(query: str, limit: int = 20) -> list[dict]:
    """Fuzzy lookup by number / display name / purpose (ILIKE + tsvector)."""
    q = (query or "").strip()
    if not q:
        return []
    pool = await get_pool()
    pattern = f"%{q}%"
    rows = await pool.fetch(
        """SELECT * FROM plans
           WHERE plan_number ILIKE $1 OR display_name ILIKE $1 OR purpose ILIKE $1
                 OR meta_tsv @@ plainto_tsquery('simple', $2)
           ORDER BY review_status, updated_at DESC LIMIT $3""",
        pattern, q, limit,
    )
    return [_plan_row_to_dict(r) for r in rows]


async def set_plan_review_status(plan_id: UUID, status: str) -> dict | None:
    """Chair gate (G10): approve / reject / reset a plan record."""
    if status not in ("pending_review", "approved", "rejected"):
        raise ValueError(f"review_status לא חוקי: {status}")
    pool = await get_pool()
    row = await pool.fetchrow(
        "UPDATE plans SET review_status = $2, updated_at = now() WHERE id = $1 RETURNING *",
        plan_id, status,
    )
    return _plan_row_to_dict(row)


# words that describe a plan but aren't its identifying code — stripped for the
# comparable "core token" used in near-duplicate detection (NOT for the key).
_PLAN_DESC_WORDS = re.compile(
    r"(?:תכנית|תוכנית|מתאר|מקומית|מחוזית|ארצית|מפורטת|כוללנית|מספר|מס['\"׳״]?)"
)


def _plan_core_token(s: str) -> str:
    """Best-effort comparable 'core' of a plan identifier — for near-duplicate
    surfacing only, NEVER a key (bare numbers collide). Strips descriptive words
    (תכנית/מתאר/מקומית/מס') and quotes, keeps the alphanumeric code:
      "תכנית מתאר מקומית מס' 62" → "62" · "מי/820" → "מי/820" · "5166/ב" → "5166/ב".
    """
    t = _normalize_plan_number(s)
    t = _PLAN_DESC_WORDS.sub(" ", t)
    t = t.replace('"', " ").replace("'", " ").replace("׳", " ").replace("״", " ")
    return " ".join(t.split()).strip()


async def find_similar_plans(
    plan_number: str,
    display_name: str = "",
    exclude_id: UUID | None = None,
    limit: int = 10,
) -> list[dict]:
    """Surface plans that LOOK like the same scheme written differently — so the
    chair can merge them (manual gate, G10). Does NOT merge. Each hit carries a
    `match_reason`. The registry is small (dozens), so we scan and score in Python.
    """
    num = _normalize_plan_number(plan_number)
    core = _plan_core_token(plan_number) or _plan_core_token(display_name)
    if not num and not core:
        return []
    pool = await get_pool()
    rows = await pool.fetch("SELECT * FROM plans")
    out: list[dict] = []
    exclude = str(exclude_id) if exclude_id else None
    for r in rows:
        d = _plan_row_to_dict(r)
        if exclude and d["id"] == exclude:
            continue
        cand_num = d["plan_number"]
        reason = None
        if num and cand_num and (num == cand_num or num in cand_num or cand_num in num):
            reason = "מספר חופף"
        elif num and num in (d.get("aliases") or []):
            reason = "alias תואם"
        elif core and core == _plan_core_token(cand_num):
            reason = "קוד-ליבה זהה"
        if reason:
            out.append({**d, "match_reason": reason})
        if len(out) >= limit:
            break
    return out


async def update_plan(
    plan_id: UUID,
    plan_number: str,
    display_name: str = "",
    plan_type: str = "",
    gazette_date=None,
    yalkut_number: str = "",
    purpose: str = "",
    aliases: list[str] | None = None,
) -> dict | None:
    """Edit a plan by id (chair UI). Re-normalizes plan_number (G1) and re-renders
    the citation. Refuses to collide with another row's number — that's a merge, not
    an edit (no silent dup). `aliases=None` keeps existing aliases.
    """
    num = _normalize_plan_number(plan_number)
    if not num:
        raise ValueError("plan_number ריק לאחר נרמול")
    gd = _coerce_plan_date(gazette_date)
    display_name = (display_name or "").strip() or num
    citation = format_plan_citation({
        "display_name": display_name, "plan_number": num,
        "gazette_date": gd, "yalkut_number": yalkut_number, "purpose": purpose,
    })
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            clash = await conn.fetchrow(
                "SELECT id FROM plans WHERE plan_number = $1 AND id <> $2", num, plan_id,
            )
            if clash:
                raise ValueError(
                    f"מספר-תכנית {num} כבר קיים ברשומה אחרת — השתמש במיזוג (merge), לא בעריכה"
                )
            row = await conn.fetchrow(
                """UPDATE plans SET
                       plan_number = $2, display_name = $3, plan_type = $4,
                       gazette_date = $5, yalkut_number = $6, purpose = $7,
                       citation_formatted = $8,
                       aliases = COALESCE($9, aliases), updated_at = now()
                   WHERE id = $1 RETURNING *""",
                plan_id, num, display_name, plan_type or "", gd,
                yalkut_number or "", purpose or "", citation,
                sorted(set(aliases)) if aliases is not None else None,
            )
    return _plan_row_to_dict(row)


async def merge_plans(source_id: UUID, target_id: UUID) -> dict:
    """Fold `source` into `target` (chair-initiated): union aliases (+ source's
    number/name as aliases), fill empty target validity from source, record any
    CONFLICTING validity in target.discrepancies (no silent loss), delete source.
    Returns the merged target.
    """
    if source_id == target_id:
        raise ValueError("מקור ויעד זהים")
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            src = await conn.fetchrow("SELECT * FROM plans WHERE id = $1 FOR UPDATE", source_id)
            tgt = await conn.fetchrow("SELECT * FROM plans WHERE id = $1 FOR UPDATE", target_id)
            if not src or not tgt:
                raise ValueError("מקור או יעד לא נמצאו")
            src, tgt = dict(src), dict(tgt)

            aliases = set(tgt.get("aliases") or []) | set(src.get("aliases") or [])
            for a in (src["plan_number"], src["display_name"]):
                if a and a not in (tgt["plan_number"], tgt["display_name"]):
                    aliases.add(a)

            disc = tgt.get("discrepancies") or []
            if isinstance(disc, str):
                disc = json.loads(disc)
            for field in ("gazette_date", "yalkut_number", "purpose"):
                sv, tv = src[field], tgt[field]
                if sv and tv and str(sv) != str(tv):
                    disc.append({
                        "field": field, "old": str(tv), "new": str(sv),
                        "source_case_number": src.get("source_case_number") or "",
                        "via": "merge",
                    })

            new_gd = tgt["gazette_date"] or src["gazette_date"]
            new_yalkut = tgt["yalkut_number"] or src["yalkut_number"]
            new_purpose = tgt["purpose"] or src["purpose"]
            new_type = tgt["plan_type"] or src["plan_type"]
            citation = format_plan_citation({
                "display_name": tgt["display_name"], "plan_number": tgt["plan_number"],
                "gazette_date": new_gd, "yalkut_number": new_yalkut, "purpose": new_purpose,
            })
            row = await conn.fetchrow(
                """UPDATE plans SET aliases = $2, gazette_date = $3, yalkut_number = $4,
                       purpose = $5, plan_type = $6, citation_formatted = $7,
                       discrepancies = $8, updated_at = now()
                   WHERE id = $1 RETURNING *""",
                target_id, sorted(aliases), new_gd, new_yalkut, new_purpose,
                new_type, citation, json.dumps(disc, ensure_ascii=False),
            )
            await conn.execute("DELETE FROM plans WHERE id = $1", source_id)
    return _plan_row_to_dict(row)


# ── V7: External precedent library + halachot ─────────────────────


def _row_to_case_law(row: asyncpg.Record) -> dict:
    """Normalize a case_law row, parsing subject_tags JSONB to list."""
    d = dict(row)
    if isinstance(d.get("subject_tags"), str):
        try:
            d["subject_tags"] = json.loads(d["subject_tags"])
        except (TypeError, ValueError):
            d["subject_tags"] = []
    if d.get("date") is not None:
        d["date"] = d["date"].isoformat()
    return d


async def get_case_law(case_law_id: UUID) -> dict | None:
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT * FROM case_law WHERE id = $1", case_law_id,
    )
    return _row_to_case_law(row) if row else None


async def get_external_case_law_by_citation(citation: str) -> dict | None:
    """Return the first external_upload row whose case_number matches citation, or None."""
    pool = await get_pool()
    row = await pool.fetchrow(
        """
        SELECT id, case_number, case_name, court, date,
               halacha_extraction_status, source_kind, created_at
        FROM case_law
        WHERE case_number = $1
          AND source_kind = 'external_upload'
        LIMIT 1
        """,
        citation,
    )
    return _row_to_case_law(row) if row else None


async def mark_indexed(case_law_id: UUID) -> None:
    """Mark a case_law row's embeddings as built from its current content (FU-3).

    Sets indexed_hash := content_hash. Call AFTER a successful chunk+embed+store.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "UPDATE case_law SET indexed_hash = content_hash WHERE id = $1",
            case_law_id,
        )


async def list_stale_case_law(limit: int = 500) -> list[dict]:
    """case_law rows whose embeddings are stale vs current content (GAP-09/INV-G6)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            """SELECT id, case_number, source_kind
               FROM case_law
               WHERE coalesce(full_text, '') <> ''
                 AND content_hash IS DISTINCT FROM indexed_hash
               ORDER BY created_at LIMIT $1""",
            limit,
        )
    return [dict(r) for r in rows]


async def recompute_content_hashes() -> dict:
    """Backfill (FU-3): set content_hash for all rows; set indexed_hash=content_hash
    only where chunks already exist (those are already embedded). Rows with text but
    no chunks get indexed_hash=NULL → surface as stale. Hash-only; no re-embed."""
    pool = await get_pool()
    updated = 0
    async with pool.acquire() as conn:
        rows = await conn.fetch("SELECT id, full_text FROM case_law")
        for r in rows:
            ch = _content_hash(r["full_text"] or "")
            has_chunks = await conn.fetchval(
                "SELECT EXISTS(SELECT 1 FROM precedent_chunks WHERE case_law_id = $1)",
                r["id"])
            await conn.execute(
                "UPDATE case_law SET content_hash = $2, "
                "indexed_hash = CASE WHEN $3 THEN $2 ELSE indexed_hash END WHERE id = $1",
                r["id"], ch, bool(has_chunks))
            updated += 1
    return {"updated": updated}


async def add_case_law_relation(
    a_id: UUID, b_id: UUID, relation_type: str = "same_case_chain"
) -> None:
    """Link two case_law records bidirectionally. Idempotent (ON CONFLICT DO NOTHING)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.executemany(
            """
            INSERT INTO case_law_relations(case_law_id, related_id, relation_type)
            VALUES($1, $2, $3)
            ON CONFLICT (case_law_id, related_id) DO NOTHING
            """,
            [(a_id, b_id, relation_type), (b_id, a_id, relation_type)],
        )


async def remove_case_law_relation(a_id: UUID, b_id: UUID) -> None:
    """Remove a bidirectional link between two case_law records."""
    pool = await get_pool()
    await pool.execute(
        """
        DELETE FROM case_law_relations
        WHERE (case_law_id = $1 AND related_id = $2)
           OR (case_law_id = $2 AND related_id = $1)
        """,
        a_id,
        b_id,
    )


async def get_case_law_relations(case_law_id: UUID) -> list[dict]:
    """Return all case_law records linked to case_law_id, ordered by date asc."""
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT cl.*, r.relation_type
        FROM case_law_relations r
        JOIN case_law cl ON cl.id = r.related_id
        WHERE r.case_law_id = $1
        ORDER BY cl.date ASC NULLS LAST
        """,
        case_law_id,
    )
    results = []
    for row in rows:
        d = dict(row)
        relation_type = d.pop("relation_type")
        normalized = _row_to_case_law(d)
        normalized["relation_type"] = relation_type
        results.append(normalized)
    return results


async def get_case_law_by_citation(case_number: str) -> dict | None:
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT * FROM case_law WHERE case_number = $1", case_number,
    )
    return _row_to_case_law(row) if row else None


async def case_number_collides(case_number: str, exclude_id: UUID) -> bool:
    """True if assigning ``case_number`` to a NON-internal row would violate the
    partial unique index ``uq_case_law_external_number`` (``case_number`` WHERE
    source_kind <> 'internal_committee') — i.e. another non-internal row already owns
    that docket. Lets a caller SKIP the identity normalization (a duplicate to dedupe
    later) instead of crashing the whole operation on the unique violation."""
    pool = await get_pool()
    return bool(await pool.fetchval(
        "SELECT 1 FROM case_law WHERE case_number = $1 AND id <> $2 "
        "AND source_kind <> 'internal_committee' LIMIT 1",
        case_number, exclude_id,
    ))


# Subject-tag convention for case_law topic hubs (powers /graph topic nodes).
# A simple multi-word Hebrew phrase is stored with underscores between words
# ("היטל_השבחה") — the documented extraction contract (precedent_library tool
# examples: קווי_בניין, מועד_קביעת_שומה) that ~99% of the corpus already follows.
# The same phrase arriving with spaces ("היטל השבחה") is the same topic in
# disguise and spawns a duplicate graph hub. Normalize at the single case_law
# write chokepoint (G1) so no path can re-introduce the split. Tags carrying
# punctuation/digits/dashes (e.g. "פטור מותנה — סעיף 19(ג)") are left untouched —
# the convention covers only plain Hebrew word phrases.
_PURE_HEBREW_WORDS = re.compile(r"^[א-ת]+(?: [א-ת]+)+$")


def _normalize_subject_tags(tags) -> list[str]:
    """Strip, apply the underscore convention to plain Hebrew phrases, dedup."""
    out: list[str] = []
    seen: set[str] = set()
    for raw in tags or []:
        t = str(raw).strip()
        if not t:
            continue
        if _PURE_HEBREW_WORDS.match(t):
            t = t.replace(" ", "_")
        if t not in seen:
            seen.add(t)
            out.append(t)
    return out


async def create_external_case_law(
    case_number: str,
    case_name: str,
    full_text: str,
    court: str = "",
    decision_date: date | None = None,
    practice_area: str = "",
    appeal_subtype: str = "",
    subject_tags: list[str] | None = None,
    summary: str = "",
    headnote: str = "",
    key_quote: str = "",
    source_url: str = "",
    source_type: str = "",
    precedent_level: str = "",
    is_binding: bool = True,
    document_id: UUID | None = None,
    citation_formatted: str = "",
) -> dict:
    """Insert a chair-uploaded external precedent into case_law.

    If a row with this ``case_number`` already exists with
    source_kind='cited_only' (auto-discovered), promote it to
    source_kind='external_upload' and fill in the missing fields.

    ``citation_formatted`` seeds the מראה-מקום from the chair's typed upload-form
    value so the field is NEVER blank between upload and metadata extraction (the
    inline enrichment later UPGRADES it to the canonical derived form via
    ``apply_to_record(force_citation=True)``). On a cited_only→external promotion
    an existing non-empty value (a prior chair edit) is preserved.
    """
    # INV-DM7: an appeals-committee source is persuasive even when uploaded via
    # the external path — coerce to non-binding so it matches the committee
    # invariant and case_law_committee_not_binding_check (G1).
    if source_type == "appeals_committee":
        is_binding = False
    pool = await get_pool()
    tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False)
    async with pool.acquire() as conn:
        # Atomic upsert on the V15 partial unique index
        # uq_case_law_external_number (case_number) WHERE source_kind <> 'internal_committee'.
        # The predicate is repeated in ON CONFLICT (required for partial indexes).
        # This also subsumes the old cited_only→external_upload promotion: a
        # cited_only row with the same case_number conflicts and is promoted by
        # DO UPDATE. Scoped to the external partial index, so an internal row with
        # the same number is NOT touched (the old SELECT-without-source_kind could
        # wrongly promote it).
        row = await conn.fetchrow(
            """
            INSERT INTO case_law (
                case_number, case_name, court, date, subject_tags,
                summary, key_quote, full_text, source_url,
                source_kind, document_id, extraction_status,
                halacha_extraction_status, practice_area, appeal_subtype,
                headnote, source_type, precedent_level, is_binding, content_hash,
                citation_formatted
            ) VALUES (
                $1, $2, $3, $4, $5, $6, $7, $8, $9,
                'external_upload', $10, 'processing', 'pending',
                $11, $12, $13, $14, $15, $16, $17, $18
            )
            ON CONFLICT (case_number) WHERE source_kind <> 'internal_committee'
            DO UPDATE SET
                case_name = EXCLUDED.case_name,
                court = COALESCE(NULLIF(EXCLUDED.court, ''), case_law.court),
                date = COALESCE(EXCLUDED.date, case_law.date),
                practice_area = EXCLUDED.practice_area,
                appeal_subtype = EXCLUDED.appeal_subtype,
                subject_tags = EXCLUDED.subject_tags,
                summary = COALESCE(NULLIF(EXCLUDED.summary, ''), case_law.summary),
                headnote = EXCLUDED.headnote,
                key_quote = COALESCE(NULLIF(EXCLUDED.key_quote, ''), case_law.key_quote),
                full_text = EXCLUDED.full_text,
                source_url = COALESCE(NULLIF(EXCLUDED.source_url, ''), case_law.source_url),
                source_type = EXCLUDED.source_type,
                precedent_level = EXCLUDED.precedent_level,
                is_binding = EXCLUDED.is_binding,
                document_id = COALESCE(EXCLUDED.document_id, case_law.document_id),
                source_kind = 'external_upload',
                extraction_status = 'processing',
                halacha_extraction_status = 'pending',
                content_hash = EXCLUDED.content_hash,
                citation_formatted = COALESCE(
                    NULLIF(case_law.citation_formatted, ''), EXCLUDED.citation_formatted)
            RETURNING *
            """,
            case_number, case_name, court, decision_date, tags_json,
            summary, key_quote, full_text, source_url,
            document_id, practice_area, appeal_subtype, headnote,
            source_type, precedent_level, is_binding,
            _content_hash(full_text), (citation_formatted or "").strip(),
        )
    return _row_to_case_law(row)


async def create_internal_committee_decision(
    case_number: str,
    case_name: str,
    full_text: str,
    court: str = "",
    decision_date: date | None = None,
    chair_name: str = "",
    district: str = "",
    practice_area: str = "",
    appeal_subtype: str = "",
    subject_tags: list[str] | None = None,
    summary: str = "",
    is_binding: bool = False,
    document_id: UUID | None = None,
    proceeding_type: str = "ערר",
) -> dict:
    """Upsert an appeals-committee decision as source_kind='internal_committee'.

    Idempotency key: (case_number, proceeding_type) — the same number can
    exist as both 'ערר' and 'בל"מ' (an extension-of-time request can be
    filed against an existing appeal with the same number).
    """
    # INV-DM7: authority is structural for this source — an appeals-committee
    # decision is persuasive, never binding. Coerce regardless of caller so the
    # value can never violate case_law_committee_not_binding_check (G1).
    is_binding = False
    pool = await get_pool()
    case_number = _canonical_case_number(case_number)
    tags_json = json.dumps(_normalize_subject_tags(subject_tags), ensure_ascii=False)
    async with pool.acquire() as conn:
        # Atomic upsert on V15 partial unique index
        # uq_case_law_internal_number_proc (case_number, proceeding_type)
        # WHERE source_kind = 'internal_committee'. Predicate repeated for the
        # partial index. Replaces the old SELECT-then-INSERT/UPDATE (race-prone).
        row = await conn.fetchrow(
            """
            INSERT INTO case_law (
                case_number, case_name, court, date, chair_name, district,
                subject_tags, summary, full_text,
                source_kind, source_type, document_id,
                extraction_status, halacha_extraction_status,
                metadata_extraction_status,
                practice_area, appeal_subtype, is_binding, proceeding_type, content_hash
            ) VALUES (
                $1, $2, $3, $4, $5, $6,
                $7, $8, $9,
                'internal_committee', 'appeals_committee', $10,
                'processing', 'pending',
                -- Internal committee decisions carry deterministic metadata
                -- computed from the case record (see app.py uniform-citation
                -- builder); the Gemini metadata extractor is tuned for EXTERNAL
                -- rulings and returns no_metadata for these. Settle the metadata
                -- status to 'completed' at the source so these rows never linger
                -- as phantom 'pending' backlog the drain can never clear.
                'completed',
                $11, $12, $13, $14, $15
            )
            ON CONFLICT (case_number, proceeding_type)
                WHERE source_kind = 'internal_committee'
            DO UPDATE SET
                case_name = EXCLUDED.case_name,
                court = COALESCE(NULLIF(EXCLUDED.court, ''), case_law.court),
                date = COALESCE(EXCLUDED.date, case_law.date),
                chair_name = COALESCE(NULLIF(EXCLUDED.chair_name, ''), case_law.chair_name),
                district = COALESCE(NULLIF(EXCLUDED.district, ''), case_law.district),
                practice_area = EXCLUDED.practice_area,
                appeal_subtype = EXCLUDED.appeal_subtype,
                subject_tags = EXCLUDED.subject_tags,
                summary = COALESCE(NULLIF(EXCLUDED.summary, ''), case_law.summary),
                full_text = EXCLUDED.full_text,
                source_type = 'appeals_committee',
                source_kind = 'internal_committee',
                is_binding = EXCLUDED.is_binding,
                document_id = COALESCE(EXCLUDED.document_id, case_law.document_id),
                extraction_status = 'processing',
                halacha_extraction_status = 'pending',
                -- Keep metadata settled on re-upsert (deterministic, never Gemini).
                metadata_extraction_status = 'completed',
                content_hash = EXCLUDED.content_hash
            RETURNING *
            """,
            case_number, case_name, court, decision_date, chair_name, district,
            tags_json, summary, full_text,
            document_id, practice_area, appeal_subtype, is_binding,
            proceeding_type, _content_hash(full_text),
        )
    return _row_to_case_law(row)


def _compute_searchable(row: dict, has_embedded_chunk: bool) -> bool:
    """Completeness contract (INV-DM1 / 02-data-model §2a).

    A row is searchable IFF: canonical id present · case_name/practice_area/
    source_kind present · ≥1 chunk with a non-null embedding · extraction
    completed · metadata non-empty (≥1 of headnote/summary/subject_tags).
    Pure — `has_embedded_chunk` is supplied by the caller (cross-table check).
    """
    if not has_embedded_chunk:
        return False
    if (row.get("extraction_status") or "") != "completed":
        return False
    if not (row.get("case_number") or "").strip():
        return False
    if not (row.get("case_name") or "").strip():
        return False
    # practice_area is required only for identifier-keyed corpora (internal
    # committee decisions, active cases). External precedents (e.g. בג"ץ) are
    # legitimately cross-domain and may have no single practice_area.
    if (row.get("source_kind") or "") != "external_upload":
        if not (row.get("practice_area") or "").strip():
            return False
    if not (row.get("source_kind") or "").strip():
        return False
    tags = row.get("subject_tags") or []
    has_meta = bool((row.get("headnote") or "").strip()) \
        or bool((row.get("summary") or "").strip()) \
        or (len(tags) > 0)
    return has_meta


async def recompute_searchable(case_law_id: "UUID | str | None" = None) -> int:
    """Recompute and persist the `searchable` flag. Idempotent / reversible.

    If case_law_id is None, recompute ALL rows (used by the V21 backfill and
    the dry-run). Returns the number of rows now marked searchable=true.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        if case_law_id is not None:
            cid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
            rows = await conn.fetch(
                "SELECT * FROM case_law WHERE id = $1", cid)
        else:
            rows = await conn.fetch("SELECT * FROM case_law")
        n_true = 0
        for r in rows:
            row = dict(r)
            tags = row.get("subject_tags")
            if isinstance(tags, str):
                try:
                    tags = json.loads(tags)
                except (ValueError, TypeError):
                    tags = []
            row["subject_tags"] = tags or []
            has_chunk = await conn.fetchval(
                "SELECT EXISTS(SELECT 1 FROM precedent_chunks "
                "WHERE case_law_id = $1 AND embedding IS NOT NULL)", row["id"])
            val = _compute_searchable(row, bool(has_chunk))
            await conn.execute(
                "UPDATE case_law SET searchable = $2 WHERE id = $1", row["id"], val)
            if val:
                n_true += 1
    return n_true


async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
    """Patch metadata fields on a case_law row.

    Allowed fields: case_name, court, date, practice_area, appeal_subtype,
    subject_tags, summary, headnote, key_quote, source_url, source_type,
    precedent_level, is_binding, citation_formatted.
    """
    allowed = {
        "case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
        "subject_tags", "summary", "headnote", "nevo_ratio", "key_quote", "source_url",
        "source_type", "precedent_level", "is_binding", "district", "chair_name",
        "proceeding_type", "citation_formatted", "parties",
    }
    updates = {k: v for k, v in fields.items() if k in allowed}
    # INV-DM7: reclassifying a row to an appeals-committee source makes it
    # persuasive — coerce is_binding in the same patch so the UPDATE can't
    # violate case_law_committee_not_binding_check (e.g. the Gemini metadata
    # extractor relabels an external court_ruling as appeals_committee).
    if updates.get("source_type") == "appeals_committee":
        updates["is_binding"] = False
    if not updates:
        return await get_case_law(case_law_id)

    pool = await get_pool()
    set_parts = []
    params: list = [case_law_id]
    for i, (k, v) in enumerate(updates.items(), start=2):
        if k == "subject_tags":
            v = json.dumps(_normalize_subject_tags(v), ensure_ascii=False)
        set_parts.append(f"{k} = ${i}")
        params.append(v)
    sql = f"UPDATE case_law SET {', '.join(set_parts)} WHERE id = $1 RETURNING *"
    row = await pool.fetchrow(sql, *params)
    if row is None:
        return None
    # `searchable` is a DERIVED completeness flag (INV-DM1). It is computed at
    # ingest end and after the Gemini metadata extractor — but internal-committee
    # decisions skip Gemini, so when their summary/tags/metadata are filled later
    # (manual edit, deterministic enrichment) the flag would otherwise go stale
    # and the row stays invisible to RAG. Recompute at this write so the derived
    # value never drifts from the content (G1: normalize at the source).
    await recompute_searchable(case_law_id)
    return await get_case_law(case_law_id)


async def set_case_law_extraction_status(case_law_id: UUID, status: str) -> None:
    """Set text-extraction status. When transitioning to a terminal state
    ('completed'/'failed') we also NULL ``metadata_extraction_requested_at``
    so the local-MCP queue (`process_pending_extractions`, which scans by
    ``WHERE *_requested_at IS NOT NULL``) doesn't re-pick the row forever
    and leave the row blocked in the UI's `isPrecedentActive` check."""
    pool = await get_pool()
    if status in ("completed", "failed"):
        await pool.execute(
            "UPDATE case_law SET extraction_status = $2, "
            "metadata_extraction_requested_at = NULL WHERE id = $1",
            case_law_id, status,
        )
    else:
        await pool.execute(
            "UPDATE case_law SET extraction_status = $2 WHERE id = $1",
            case_law_id, status,
        )


async def set_case_law_halacha_status(case_law_id: UUID, status: str) -> None:
    """Set halacha-extraction status. Mirrors ``set_case_law_extraction_status``:
    on terminal states we also clear ``halacha_extraction_requested_at`` so the
    queue and UI don't see a stale request flag."""
    pool = await get_pool()
    if status in ("completed", "failed"):
        await pool.execute(
            "UPDATE case_law SET halacha_extraction_status = $2, "
            "halacha_extraction_requested_at = NULL WHERE id = $1",
            case_law_id, status,
        )
    else:
        await pool.execute(
            "UPDATE case_law SET halacha_extraction_status = $2 WHERE id = $1",
            case_law_id, status,
        )


async def set_case_law_metadata_status(case_law_id: UUID, status: str) -> None:
    """Set metadata-extraction status. Mirrors ``set_case_law_halacha_status``:
    on terminal states ('completed'/'failed') we also clear
    ``metadata_extraction_requested_at`` so the local-MCP queue
    (`process_pending_extractions`, which scans ``WHERE *_requested_at IS NOT
    NULL``) stops re-picking the row and the UI's ``isPrecedentActive`` check
    settles."""
    pool = await get_pool()
    if status in ("completed", "failed"):
        await pool.execute(
            "UPDATE case_law SET metadata_extraction_status = $2, "
            "metadata_extraction_requested_at = NULL WHERE id = $1",
            case_law_id, status,
        )
    else:
        await pool.execute(
            "UPDATE case_law SET metadata_extraction_status = $2 WHERE id = $1",
            case_law_id, status,
        )


async def list_external_case_law(
    practice_area: str = "",
    court: str = "",
    precedent_level: str = "",
    source_type: str = "",
    search: str = "",
    limit: int = 100,
    offset: int = 0,
    source_kind: str = "",
) -> list[dict]:
    """List chair-uploaded precedents, with simple filters.

    source_kind="" (default) = the whole corpus — court rulings *and*
    appeals-committee decisions. The old ``external_upload`` default hid
    every committee decision from a plain listing (#232).
    source_kind="all_committees" expands to: source_kind='internal_committee'
    OR (source_kind='external_upload' AND source_type='appeals_committee').
    """
    pool = await get_pool()
    sk_clause = _source_kind_clause(source_kind)
    conditions = [sk_clause] if sk_clause else []
    params: list = []
    idx = 1
    if practice_area:
        conditions.append(f"practice_area = ${idx}")
        params.append(practice_area)
        idx += 1
    if court:
        conditions.append(f"court ILIKE ${idx}")
        params.append(f"%{court}%")
        idx += 1
    if precedent_level:
        conditions.append(f"precedent_level = ${idx}")
        params.append(precedent_level)
        idx += 1
    if source_type:
        conditions.append(f"source_type = ${idx}")
        params.append(source_type)
        idx += 1
    if search:
        # Case-number separator normalisation (#232, trap 2): committee numbers
        # are stored hyphenated ("83-16") while people write them with a slash
        # ("83/16"). Fold both sides to '/' so either form finds the row —
        # matching at the point of comparison rather than asking every caller
        # to guess the stored form.
        conditions.append(
            f"(case_number ILIKE ${idx} OR case_name ILIKE ${idx} "
            f"OR summary ILIKE ${idx} OR headnote ILIKE ${idx} "
            f"OR replace(case_number, '-', '/') ILIKE replace(${idx}, '-', '/'))"
        )
        params.append(f"%{search}%")
        idx += 1
    where_sql = " AND ".join(conditions) if conditions else "TRUE"
    params.extend([limit, offset])
    sql = f"""
        SELECT id, case_number, case_name, court, date, practice_area,
               appeal_subtype, source_type, precedent_level, is_binding,
               summary, headnote, subject_tags, source_kind,
               chair_name, district, citation_formatted,
               extraction_status, halacha_extraction_status,
               metadata_extraction_status,
               metadata_extraction_requested_at,
               halacha_extraction_requested_at,
               created_at,
               (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id) AS halachot_count,
               (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id
                  AND h.review_status IN ('approved', 'published')) AS approved_count,
               (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id
                  AND h.review_status = 'pending_review') AS pending_count,
               (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id
                  AND h.review_status = 'rejected') AS rejected_count,
               (SELECT COUNT(*) FROM halachot h WHERE h.case_law_id = case_law.id
                  AND h.review_status = 'deferred') AS deferred_count
        FROM case_law
        WHERE {where_sql}
        ORDER BY created_at DESC
        LIMIT ${idx} OFFSET ${idx + 1}
    """
    rows = await pool.fetch(sql, *params)
    out = []
    for r in rows:
        d = _row_to_case_law(r)
        # Render timestamps as ISO strings so the JSON layer stays simple
        for k in ("metadata_extraction_requested_at", "halacha_extraction_requested_at"):
            if d.get(k) is not None:
                d[k] = d[k].isoformat()
        out.append(d)
    return out


async def delete_case_law(case_law_id: UUID) -> bool:
    """Delete a precedent and cascade chunks + halachot."""
    pool = await get_pool()
    result = await pool.execute(
        "DELETE FROM case_law WHERE id = $1", case_law_id,
    )
    return result == "DELETE 1"


# ── Digests (X12 — radar layer; separate table, INV-DIG1/2/3) ────────

_DIGEST_COLS = (
    "id, yomon_number, digest_date, publication, source_firm, concept_tag, "
    "headline_holding, analysis_text, summary, underlying_citation, "
    "underlying_court, underlying_date, underlying_judge, practice_area, "
    "appeal_subtype, subject_tags, linked_case_law_id, source_document_path, "
    "content_hash, extraction_status, digest_kind, created_at, updated_at"
)

_DIGEST_UPDATE_ALLOWED = {
    "yomon_number", "digest_date", "publication", "source_firm", "concept_tag",
    "headline_holding", "analysis_text", "summary", "underlying_citation",
    "underlying_court", "underlying_date", "underlying_judge", "practice_area",
    "appeal_subtype", "subject_tags", "source_document_path", "content_hash",
    "extraction_status", "digest_kind",
}


def _row_to_digest(row: asyncpg.Record | dict | None) -> dict | None:
    """Normalize a digests row: ISO-format dates, ensure subject_tags is a list."""
    if row is None:
        return None
    d = dict(row)
    for k in ("digest_date", "underlying_date", "created_at", "updated_at"):
        if d.get(k) is not None and hasattr(d[k], "isoformat"):
            d[k] = d[k].isoformat()
    if d.get("subject_tags") is None:
        d["subject_tags"] = []
    if d.get("id") is not None:
        d["id"] = str(d["id"])
    if d.get("linked_case_law_id") is not None:
        d["linked_case_law_id"] = str(d["linked_case_law_id"])
    return d


async def create_digest(
    *,
    analysis_text: str,
    yomon_number: str = "",
    digest_date: date | None = None,
    publication: str = "כל יום",
    source_firm: str = "עפר טויסטר, עורכי דין",
    concept_tag: str = "",
    headline_holding: str = "",
    summary: str = "",
    underlying_citation: str = "",
    underlying_court: str = "",
    underlying_date: date | None = None,
    underlying_judge: str = "",
    practice_area: str = "",
    appeal_subtype: str = "",
    subject_tags: list[str] | None = None,
    source_document_path: str = "",
    extraction_status: str = "processing",
    digest_kind: str = "",
) -> dict:
    """Upsert a digest (X12). Idempotent on yomon_number (INV-G3): a repeat
    upload of the same yomon updates in place. content_hash is the secondary
    dedup key for digests whose number couldn't be parsed (and the primary key
    for bulletin items, which carry no yomon_number — see uq_digests_content_hash)."""
    pool = await get_pool()
    content_hash = _content_hash(analysis_text)
    async with pool.acquire() as conn:
        # Upsert on the partial unique index uq_digests_yomon_number
        # (yomon_number WHERE yomon_number <> ''). Predicate repeated in
        # ON CONFLICT as required for partial indexes.
        row = await conn.fetchrow(
            f"""
            INSERT INTO digests (
                yomon_number, digest_date, publication, source_firm, concept_tag,
                headline_holding, analysis_text, summary, underlying_citation,
                underlying_court, underlying_date, underlying_judge, practice_area,
                appeal_subtype, subject_tags, source_document_path,
                content_hash, extraction_status, digest_kind
            ) VALUES (
                $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
                $14, $15, $16, $17, $18, $19
            )
            ON CONFLICT (yomon_number) WHERE yomon_number <> ''
            DO UPDATE SET
                digest_date = COALESCE(EXCLUDED.digest_date, digests.digest_date),
                publication = EXCLUDED.publication,
                source_firm = EXCLUDED.source_firm,
                concept_tag = EXCLUDED.concept_tag,
                headline_holding = EXCLUDED.headline_holding,
                analysis_text = EXCLUDED.analysis_text,
                summary = EXCLUDED.summary,
                underlying_citation = EXCLUDED.underlying_citation,
                underlying_court = EXCLUDED.underlying_court,
                underlying_date = COALESCE(EXCLUDED.underlying_date, digests.underlying_date),
                underlying_judge = EXCLUDED.underlying_judge,
                practice_area = EXCLUDED.practice_area,
                appeal_subtype = EXCLUDED.appeal_subtype,
                subject_tags = EXCLUDED.subject_tags,
                source_document_path = COALESCE(NULLIF(EXCLUDED.source_document_path, ''), digests.source_document_path),
                content_hash = EXCLUDED.content_hash,
                extraction_status = EXCLUDED.extraction_status,
                digest_kind = COALESCE(NULLIF(EXCLUDED.digest_kind, ''), digests.digest_kind),
                updated_at = now()
            RETURNING {_DIGEST_COLS}
            """,
            yomon_number, digest_date, publication, source_firm, concept_tag,
            headline_holding, analysis_text, summary, underlying_citation,
            underlying_court, underlying_date, underlying_judge, practice_area,
            appeal_subtype, list(subject_tags or []), source_document_path,
            content_hash, extraction_status, digest_kind,
        )
    return _row_to_digest(row)


async def get_digest(digest_id: UUID | str) -> dict | None:
    pool = await get_pool()
    cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
    row = await pool.fetchrow(
        f"SELECT {_DIGEST_COLS} FROM digests WHERE id = $1", cid,
    )
    return _row_to_digest(row)


async def get_digest_by_content_hash(content_hash: str) -> dict | None:
    if not content_hash:
        return None
    pool = await get_pool()
    row = await pool.fetchrow(
        f"SELECT {_DIGEST_COLS} FROM digests WHERE content_hash = $1", content_hash,
    )
    return _row_to_digest(row)


async def update_digest(digest_id: UUID | str, **fields) -> dict | None:
    """Patch metadata fields on a digest row. Whitelist via _DIGEST_UPDATE_ALLOWED."""
    cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
    updates = {k: v for k, v in fields.items() if k in _DIGEST_UPDATE_ALLOWED}
    if not updates:
        return await get_digest(cid)
    pool = await get_pool()
    set_parts = []
    params: list = [cid]
    for i, (k, v) in enumerate(updates.items(), start=2):
        if k == "subject_tags":
            v = list(v or [])
        set_parts.append(f"{k} = ${i}")
        params.append(v)
    set_parts.append("updated_at = now()")
    sql = f"UPDATE digests SET {', '.join(set_parts)} WHERE id = $1 RETURNING {_DIGEST_COLS}"
    row = await pool.fetchrow(sql, *params)
    return _row_to_digest(row)


async def store_digest_embedding(digest_id: UUID | str, vector: list[float]) -> None:
    pool = await get_pool()
    cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
    await pool.execute(
        "UPDATE digests SET embedding = $2, updated_at = now() WHERE id = $1",
        cid, vector,
    )


async def link_digest_to_case_law(
    digest_id: UUID | str, case_law_id: UUID | str | None,
) -> dict | None:
    """Set (or clear, with None) the bridge to the underlying ruling (INV-DIG3)."""
    pool = await get_pool()
    cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
    clid = None
    if case_law_id is not None:
        clid = case_law_id if isinstance(case_law_id, UUID) else UUID(str(case_law_id))
    row = await pool.fetchrow(
        f"UPDATE digests SET linked_case_law_id = $2, updated_at = now() "
        f"WHERE id = $1 RETURNING {_DIGEST_COLS}",
        cid, clid,
    )
    return _row_to_digest(row)


async def delete_digest(digest_id: UUID | str) -> bool:
    pool = await get_pool()
    cid = digest_id if isinstance(digest_id, UUID) else UUID(str(digest_id))
    result = await pool.execute("DELETE FROM digests WHERE id = $1", cid)
    return result == "DELETE 1"


async def list_digests(
    practice_area: str = "",
    concept_tag: str = "",
    linked: bool | None = None,
    search: str = "",
    publication: str = "",
    limit: int = 100,
    offset: int = 0,
) -> list[dict]:
    """List digests with simple filters. linked=True/False filters on whether
    the underlying ruling is in the library yet (INV-DIG3 gap surfacing).
    publication filters the source ('כל יום' daily vs 'עו"ד על נדל"ן' monthly)."""
    pool = await get_pool()
    conditions: list[str] = []
    params: list = []
    idx = 1
    if practice_area:
        conditions.append(f"practice_area = ${idx}")
        params.append(practice_area)
        idx += 1
    if publication:
        conditions.append(f"publication = ${idx}")
        params.append(publication)
        idx += 1
    if concept_tag:
        conditions.append(f"concept_tag ILIKE ${idx}")
        params.append(f"%{concept_tag}%")
        idx += 1
    if linked is True:
        conditions.append("linked_case_law_id IS NOT NULL")
    elif linked is False:
        conditions.append("linked_case_law_id IS NULL")
    if search:
        conditions.append(
            f"(yomon_number ILIKE ${idx} OR concept_tag ILIKE ${idx} "
            f"OR headline_holding ILIKE ${idx} OR underlying_citation ILIKE ${idx} "
            f"OR summary ILIKE ${idx})"
        )
        params.append(f"%{search}%")
        idx += 1
    where_sql = (" WHERE " + " AND ".join(conditions)) if conditions else ""
    params.extend([limit, offset])
    sql = (
        f"SELECT {_DIGEST_COLS} FROM digests{where_sql} "
        f"ORDER BY digest_date DESC NULLS LAST, created_at DESC "
        f"LIMIT ${idx} OFFSET ${idx + 1}"
    )
    rows = await pool.fetch(sql, *params)
    return [_row_to_digest(r) for r in rows]


async def list_pending_digests(limit: int = 20) -> list[dict]:
    """Digests awaiting local LLM enrichment (web-upload queue, X12). The
    drainer (digest_library.process_pending_digests) picks these up."""
    pool = await get_pool()
    rows = await pool.fetch(
        f"SELECT {_DIGEST_COLS} FROM digests WHERE extraction_status = 'pending' "
        f"ORDER BY created_at LIMIT $1",
        limit,
    )
    return [_row_to_digest(r) for r in rows]


async def search_digests_semantic(
    query_embedding: list[float],
    practice_area: str = "",
    subject_tag: str = "",
    concept_tag: str = "",
    limit: int = 10,
    linked_only: bool | None = None,
) -> list[dict]:
    """Pure-semantic search over the digests radar (X12). Single vector per row
    (no chunks/halachot), so no RRF here — see X12 §6. Joins the linked ruling's
    citation when present so the researcher sees the pointer target directly.

    ``linked_only``: None = all digests (default); False = only UNLINKED digests
    (``linked_case_law_id IS NULL`` — rulings we don't hold yet, the case radar's
    target set); True = only linked digests.
    """
    pool = await get_pool()
    conditions = ["d.embedding IS NOT NULL"]
    params: list = [query_embedding, limit]
    idx = 3
    if linked_only is True:
        conditions.append("d.linked_case_law_id IS NOT NULL")
    elif linked_only is False:
        conditions.append("d.linked_case_law_id IS NULL")
    if practice_area:
        conditions.append(f"d.practice_area = ${idx}")
        params.append(practice_area)
        idx += 1
    if subject_tag:
        conditions.append(f"${idx} = ANY(d.subject_tags)")
        params.append(subject_tag)
        idx += 1
    if concept_tag:
        conditions.append(f"d.concept_tag ILIKE ${idx}")
        params.append(f"%{concept_tag}%")
        idx += 1
    sql = f"""
        SELECT {', '.join('d.' + c for c in _DIGEST_COLS.split(', '))},
               cl.case_number AS linked_case_number,
               cl.case_name AS linked_case_name,
               cl.searchable AS linked_searchable,
               1 - (d.embedding <=> $1) AS score
        FROM digests d
        LEFT JOIN case_law cl ON cl.id = d.linked_case_law_id
        WHERE {' AND '.join(conditions)}
        ORDER BY d.embedding <=> $1
        LIMIT $2
    """
    rows = await pool.fetch(sql, *params)
    out = []
    for r in rows:
        d = _row_to_digest(r)
        d["linked_case_number"] = r["linked_case_number"]
        d["linked_case_name"] = r["linked_case_name"]
        d["linked_searchable"] = r["linked_searchable"]
        d["score"] = float(r["score"])
        d["type"] = "digest"
        out.append(d)
    return out


async def find_case_law_by_citation_fuzzy(citation: str) -> dict | None:
    """Best-effort match of a digest's underlying_citation to a case_law row,
    for autolink (INV-DIG3). Tries: (1) exact case_number; (2) canonical docket
    substring (e.g. '46111-12-22') contained in a case_law.case_number. Returns
    the first match or None — never raises, never mutates."""
    citation = (citation or "").strip()
    if not citation:
        return None
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT * FROM case_law WHERE case_number = $1 LIMIT 1",
        citation,
    )
    if row:
        return _row_to_case_law(row)
    # Extract a docket-like token: digits with '-' or '/' separators, e.g.
    # 46111-12-22 or 3975/22. Match it as a substring of case_number.
    m = re.search(r"\d+[-/]\d+(?:[-/]\d+)?", citation)
    if not m:
        return None
    docket = m.group(0)
    row = await pool.fetchrow(
        "SELECT * FROM case_law "
        "WHERE case_number ILIKE $1 ORDER BY created_at LIMIT 1",
        f"%{docket}%",
    )
    return _row_to_case_law(row) if row else None


async def store_precedent_chunks(
    case_law_id: UUID, chunks: list[dict],
) -> int:
    """Replace precedent chunks for a case_law row (single-tier).

    Each chunk dict has: chunk_index, content, section_type, page_number,
    embedding (list[float] or None).

    All rows written here are stored with ``chunk_role='child'`` and
    ``parent_chunk_id IS NULL`` — backward-compatible with the V17
    schema (parent-doc lookup is a no-op for these rows). For two-tier
    ingestion, see :func:`store_precedent_chunks_hierarchical`.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "DELETE FROM precedent_chunks WHERE case_law_id = $1",
            case_law_id,
        )
        for c in chunks:
            await conn.execute(
                """INSERT INTO precedent_chunks
                   (case_law_id, chunk_index, content, section_type,
                    page_number, embedding)
                   VALUES ($1, $2, $3, $4, $5, $6)""",
                case_law_id,
                c["chunk_index"],
                c["content"],
                c.get("section_type", "other"),
                c.get("page_number"),
                c.get("embedding"),
            )
    return len(chunks)


async def store_precedent_chunks_hierarchical(
    case_law_id: UUID,
    chunks: list[dict],
) -> dict:
    """Replace precedent chunks for a case_law row (two-tier).

    Each input dict must carry:
        * ``role``: 'child' | 'parent'
        * ``local_id``: in-batch identifier (int) used to wire children
          to their parent's DB UUID
        * ``parent_local_id``: int (only for children) — references the
          ``local_id`` of the parent in this same batch. For parents,
          this is None.
        * ``chunk_index``, ``content``, ``section_type``, ``page_number``
        * ``embedding``: required for children, None for parents

    Two-pass write inside a single transaction:
      1. INSERT all parents (no FK back to children), capture
         ``local_id → DB UUID`` map.
      2. INSERT all children with ``parent_chunk_id`` resolved.

    Returns ``{"parents": N, "children": M, "total": N+M}``.
    """
    parents = [c for c in chunks if c.get("role") == "parent"]
    children = [c for c in chunks if c.get("role") == "child"]
    if not parents and not children:
        return {"parents": 0, "children": 0, "total": 0}

    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "DELETE FROM precedent_chunks WHERE case_law_id = $1",
                case_law_id,
            )
            # Pass 1: parents — embedding intentionally NULL (parents
            # aren't matched on; they only carry retrieval context).
            local_to_uuid: dict[int, UUID] = {}
            for p in parents:
                row = await conn.fetchrow(
                    """INSERT INTO precedent_chunks
                       (case_law_id, chunk_index, content, section_type,
                        page_number, embedding, chunk_role, parent_chunk_id)
                       VALUES ($1, $2, $3, $4, $5, NULL, 'parent', NULL)
                       RETURNING id""",
                    case_law_id,
                    p["chunk_index"],
                    p["content"],
                    p.get("section_type", "other"),
                    p.get("page_number"),
                )
                local_to_uuid[int(p["local_id"])] = row["id"]

            # Pass 2: children with resolved parent_chunk_id.
            for c in children:
                parent_uuid = local_to_uuid.get(
                    int(c["parent_local_id"])
                ) if c.get("parent_local_id") is not None else None
                await conn.execute(
                    """INSERT INTO precedent_chunks
                       (case_law_id, chunk_index, content, section_type,
                        page_number, embedding, chunk_role, parent_chunk_id)
                       VALUES ($1, $2, $3, $4, $5, $6, 'child', $7)""",
                    case_law_id,
                    c["chunk_index"],
                    c["content"],
                    c.get("section_type", "other"),
                    c.get("page_number"),
                    c.get("embedding"),
                    parent_uuid,
                )
    return {
        "parents": len(parents),
        "children": len(children),
        "total": len(parents) + len(children),
    }


async def list_precedent_chunks(
    case_law_id: UUID,
    section_types: tuple[str, ...] | None = None,
) -> list[dict]:
    pool = await get_pool()
    if section_types:
        rows = await pool.fetch(
            """SELECT id, chunk_index, content, section_type, page_number,
                      halacha_extracted_at
               FROM precedent_chunks
               WHERE case_law_id = $1 AND section_type = ANY($2::text[])
               ORDER BY chunk_index""",
            case_law_id, list(section_types),
        )
    else:
        rows = await pool.fetch(
            """SELECT id, chunk_index, content, section_type, page_number,
                      halacha_extracted_at
               FROM precedent_chunks
               WHERE case_law_id = $1
               ORDER BY chunk_index""",
            case_law_id,
        )
    return [dict(r) for r in rows]


async def delete_halachot(case_law_id: UUID) -> int:
    pool = await get_pool()
    result = await pool.execute(
        "DELETE FROM halachot WHERE case_law_id = $1", case_law_id,
    )
    # result is e.g. "DELETE 5" — extract the number.
    try:
        return int(result.split()[-1])
    except (ValueError, IndexError):
        return 0


async def store_halachot(case_law_id: UUID, halachot: list[dict]) -> int:
    """Bulk-insert extracted halachot.

    Each halacha enters with review_status determined by extractor
    confidence vs ``config.HALACHA_AUTO_APPROVE_THRESHOLD``:
      - confidence >= threshold → 'approved' (visible to search immediately)
      - else → 'pending_review' (chair must approve manually)

    The auto-approval reviewer is recorded as 'auto' for traceability.
    """
    if not halachot:
        return 0
    threshold = config.HALACHA_AUTO_APPROVE_THRESHOLD
    pool = await get_pool()
    async with pool.acquire() as conn:
        for i, h in enumerate(halachot):
            confidence = float(h.get("confidence", 0.0))
            # #153: no review queue → everything is available background (approved).
            auto_approve = config.HALACHA_NO_REVIEW_QUEUE or confidence >= threshold
            review_status = "approved" if auto_approve else "pending_review"
            reviewer = (
                f"auto-approved (confidence ≥ {threshold:.2f})"
                if auto_approve else None
            )
            reviewed_at_clause = "now()" if auto_approve else "NULL"
            await conn.execute(
                f"""INSERT INTO halachot
                   (case_law_id, halacha_index, rule_statement, rule_type,
                    reasoning_summary, supporting_quote, page_reference,
                    practice_areas, subject_tags, cites, confidence,
                    quote_verified, embedding, review_status,
                    reviewer, reviewed_at)
                   VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
                           $12, $13, $14, $15, {reviewed_at_clause})""",
                case_law_id,
                i,
                h["rule_statement"],
                h.get("rule_type", "interpretive"),
                h.get("reasoning_summary", ""),
                h["supporting_quote"],
                h.get("page_reference", ""),
                h.get("practice_areas", []),
                h.get("subject_tags", []),
                h.get("cites", []),
                confidence,
                h.get("quote_verified", False),
                h.get("embedding"),
                review_status,
                reviewer,
            )
    return len(halachot)


async def reset_halacha_extraction(case_law_id: UUID) -> dict:
    """Prepare a clean re-extraction WITHOUT destroying chair-approved work.

    Deletes only un-reviewed halachot (``review_status NOT IN ('approved',
    'published')``) and clears per-chunk checkpoints so every chunk is
    re-processed. Chair-approved / published halachot are PRESERVED — INV-G10:
    a human approval is never silently deleted by a re-extraction. The
    re-extractor's dedup-on-insert (:func:`store_halachot_for_chunk`) skips any
    freshly extracted halacha that duplicates a preserved one, so approvals
    survive without producing duplicates.

    History: this once wiped ALL halachot first, then re-extracted — a crash
    between the wipe and the first chunk's store lost every approval and left
    the row stuck ``status='processing'`` with 0 rows (the 2026-06-08 amiel
    incident, TaskMaster #108). Durable resume of the whole pipeline is X16/#114.

    Returns ``{"deleted": N, "preserved": M}``.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            preserved = await conn.fetchval(
                "SELECT COUNT(*) FROM halachot WHERE case_law_id = $1 "
                "AND review_status IN ('approved', 'published')", case_law_id,
            )
            tag = await conn.execute(
                "DELETE FROM halachot WHERE case_law_id = $1 "
                "AND review_status NOT IN ('approved', 'published')", case_law_id,
            )
            await conn.execute(
                "UPDATE precedent_chunks SET halacha_extracted_at = NULL "
                "WHERE case_law_id = $1", case_law_id,
            )
    try:
        deleted = int(str(tag).split()[-1])
    except (ValueError, IndexError):
        deleted = 0
    return {"deleted": deleted, "preserved": int(preserved or 0)}


async def mark_all_chunks_extracted(case_law_id: UUID) -> int:
    """Checkpoint every un-marked chunk of a precedent as extracted.

    Used to backfill pre-V25 precedents (halachot already exist but no chunk was
    checkpointed) so a resume run skips them instead of re-extracting (which
    would duplicate). Returns rows updated.
    """
    pool = await get_pool()
    result = await pool.execute(
        "UPDATE precedent_chunks SET halacha_extracted_at = now() "
        "WHERE case_law_id = $1 AND halacha_extracted_at IS NULL", case_law_id,
    )
    try:
        return int(result.split()[-1])
    except (ValueError, IndexError):
        return 0


async def store_halachot_for_chunk(
    case_law_id: UUID, chunk_id: UUID, halachot: list[dict],
) -> int:
    """Persist ONE chunk's halachot and mark the chunk done — atomically.

    Crash-safe + resumable: each chunk's results land in the DB the moment it
    finishes, and the chunk is flagged (``halacha_extracted_at``) so a resumed
    run skips it. ``halacha_index`` continues from the current max so appends
    across chunks never collide. The chunk is marked even when ``halachot`` is
    empty (so resume skips genuinely-empty chunks too). Caller serializes calls
    (a single in-process store-lock) so the MAX read stays race-free.

    Two gates encode the strict rubric (docs/halacha-strict-rubric.md) so the
    corpus stays clean at the source instead of accumulating noise:

    * Auto-approve gate — a halacha auto-approves only if confidence ≥ threshold
      AND it carries no ``quality_flags`` (non_decision / truncated_quote /
      thin_restatement / quote_unverified). Flagged items route to
      ``pending_review`` regardless of confidence.
    * Dedup-on-insert — within the SAME precedent, a halacha is skipped if its
      normalized ``supporting_quote`` already exists, or its rule-embedding has
      cosine ≥ ``HALACHA_DEDUP_COSINE`` against an already-stored halacha.

    Returns the number of halachot actually INSERTED (after dedup skips).
    """
    threshold = config.HALACHA_AUTO_APPROVE_THRESHOLD
    dedup_distance = 1.0 - config.HALACHA_DEDUP_COSINE  # cosine sim → distance
    band_distance = 1.0 - config.HALACHA_DEDUP_BAND_COSINE  # tail-band ceiling (#82.3)
    pool = await get_pool()
    inserted = 0
    skipped = 0
    async with pool.acquire() as conn:
        async with conn.transaction():
            base = await conn.fetchval(
                "SELECT COALESCE(MAX(halacha_index), -1) + 1 FROM halachot "
                "WHERE case_law_id = $1", case_law_id,
            )
            # Existing normalized quotes for exact-dedup (incl. within-batch).
            existing_quotes = {
                halacha_quality.normalize_text(r["supporting_quote"])
                for r in await conn.fetch(
                    "SELECT supporting_quote FROM halachot WHERE case_law_id = $1",
                    case_law_id,
                )
            }
            for h in halachot:
                norm_quote = halacha_quality.normalize_text(h["supporting_quote"])
                # 1) exact normalized-quote duplicate within this precedent
                if norm_quote and norm_quote in existing_quotes:
                    skipped += 1
                    continue
                # 2) semantic near-duplicate (rule embedding cosine) — fetch the
                #    nearest same-precedent neighbor once so we can both auto-skip
                #    (cosine ≥ DEDUP) and flag the lexical tail (#82.3).
                emb = h.get("embedding")
                flags = list(h.get("quality_flags") or [])
                if emb is not None and config.HALACHA_DEDUP_COSINE <= 1.0:
                    neighbor = await conn.fetchrow(
                        "SELECT id, rule_statement, cites, (embedding <=> $2) AS dist "
                        "FROM halachot WHERE case_law_id = $1 "
                        "AND embedding IS NOT NULL "
                        "ORDER BY embedding <=> $2 LIMIT 1",
                        case_law_id, emb,
                    )
                    if neighbor is not None:
                        # PAIRWISE decision vs the single nearest neighbor — no
                        # cluster closure, so a chain A~B~C can't over-merge to one
                        # row (#82.6 over-merge guard). See halacha_quality.dedup_action.
                        action = halacha_quality.dedup_action(
                            float(neighbor["dist"]), h["rule_statement"],
                            neighbor["rule_statement"], dedup_distance, band_distance,
                        )
                        if action == "skip":
                            # #82.4 — merge-with-provenance, not blind drop: fold the
                            # incoming row's cites into the surviving neighbor (the
                            # only provenance present at insert; full canonical-
                            # selection/merge lives in the offline reconciliation
                            # path, #82.7 / #84.2).
                            new_cites = [c for c in (h.get("cites") or []) if c]
                            if new_cites:
                                await conn.execute(
                                    "UPDATE halachot SET cites = ARRAY(SELECT DISTINCT "
                                    "unnest(COALESCE(cites, '{}') || $2::text[])), "
                                    "updated_at = now() WHERE id = $1",
                                    neighbor["id"], new_cites,
                                )
                            skipped += 1
                            continue
                        # tail band: below auto-skip but lexically near → flag.
                        if (action == "flag"
                                and halacha_quality.FLAG_NEAR_DUPLICATE not in flags):
                            flags.append(halacha_quality.FLAG_NEAR_DUPLICATE)

                # 3) V41 lookup-before-insert: does this principle already have a canonical?
                # If yes → 'citation' instance linked to the existing canonical.
                # If no  → 'original' instance; a new canonical is created after INSERT.
                canonical_id = None
                instance_type = "original"
                if emb is not None and config.HALACHA_CANONICAL_LOOKUP_ENABLED:
                    canon_match = await conn.fetchrow(
                        "SELECT id, 1 - (embedding <=> $1) AS sim "
                        "FROM canonical_halachot "
                        "WHERE embedding IS NOT NULL "
                        "ORDER BY embedding <=> $1 LIMIT 1",
                        emb,
                    )
                    if (canon_match
                            and float(canon_match["sim"]) >= config.HALACHA_CANONICAL_THRESHOLD):
                        canonical_id = canon_match["id"]
                        instance_type = "citation"

                confidence = float(h.get("confidence", 0.0))
                # #153: no review queue → everything is available background (approved);
                # quality flags become ranking signals, not an approval gate (nli 97% FP).
                auto_approve = config.HALACHA_NO_REVIEW_QUEUE or (confidence >= threshold and not flags)
                review_status = "approved" if auto_approve else "pending_review"
                reviewer = (
                    f"auto-approved (confidence ≥ {threshold:.2f})"
                    if auto_approve else None
                )
                reviewed_at_clause = "now()" if auto_approve else "NULL"
                await conn.execute(
                    f"""INSERT INTO halachot
                       (case_law_id, halacha_index, rule_statement, rule_type,
                        reasoning_summary, supporting_quote, page_reference,
                        practice_areas, subject_tags, cites, confidence,
                        quote_verified, quality_flags, embedding, review_status,
                        reviewer, reviewed_at, canonical_id, instance_type)
                       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11,
                               $12, $13, $14, $15, $16, {reviewed_at_clause},
                               $17, $18)""",
                    case_law_id, base + inserted, h["rule_statement"],
                    h.get("rule_type", "interpretive"), h.get("reasoning_summary", ""),
                    h["supporting_quote"], h.get("page_reference", ""),
                    h.get("practice_areas", []), h.get("subject_tags", []),
                    h.get("cites", []), confidence, h.get("quote_verified", False),
                    flags, emb, review_status, reviewer,
                    canonical_id, instance_type,
                )
                existing_quotes.add(norm_quote)
                inserted += 1

                # V41: maintain canonical_halachot after successful insert.
                if config.HALACHA_CANONICAL_LOOKUP_ENABLED:
                    if instance_type == "original":
                        # New principle — create canonical and link back.
                        new_canon_id = await conn.fetchval(
                            "INSERT INTO canonical_halachot "
                            "(canonical_statement, rule_type, practice_areas, subject_tags, "
                            " embedding, first_established_in, review_status, instance_count) "
                            "VALUES ($1,$2,$3,$4,$5,$6,'pending_synthesis',1) RETURNING id",
                            h.get("rule_statement") or "",
                            h.get("rule_type", "interpretive"),
                            h.get("practice_areas") or [],
                            h.get("subject_tags") or [],
                            emb,
                            case_law_id,
                        )
                        await conn.execute(
                            "UPDATE halachot SET canonical_id=$1 "
                            "WHERE case_law_id=$2 AND halacha_index=$3",
                            new_canon_id, case_law_id, base + inserted - 1,
                        )
                    elif canonical_id is not None:
                        # Citation of existing canonical — bump its instance count.
                        await conn.execute(
                            "UPDATE canonical_halachot SET "
                            "instance_count = instance_count + 1, updated_at = now() "
                            "WHERE id = $1",
                            canonical_id,
                        )
            await conn.execute(
                "UPDATE precedent_chunks SET halacha_extracted_at = now() "
                "WHERE id = $1", chunk_id,
            )
    if skipped:
        logger.info(
            "store_halachot_for_chunk: case_law=%s chunk=%s — %d inserted, "
            "%d skipped as duplicates", case_law_id, chunk_id, inserted, skipped,
        )
    return inserted


async def store_panel_principles(
    case_law_id: UUID, principles: list[dict],
) -> dict:
    """Persist principles selected by the tri-model panel regime (#152, Phase B).

    Unlike :func:`store_halachot_for_chunk`, the verdict is ALREADY decided by the
    panel — this does NOT recompute auto-approve from confidence. Each principle
    carries its own ``review_status`` (approved / pending_review from the panel),
    ``instance_type`` ('original' = new canonical, 'citation' = link to existing),
    and ``canonical_id`` (when a citation). The cap-of-5 + dedup-frees-slot is
    applied by the CALLER (the extractor); this is the atomic writer.

    Provenance (G9): reviewer = "panel:<voters> v<votes>/s<score>". Returns
    {created_new, linked} counts.
    """
    pool = await get_pool()
    created_new = linked = 0
    async with pool.acquire() as conn:
        async with conn.transaction():
            base = await conn.fetchval(
                "SELECT COALESCE(MAX(halacha_index), -1) + 1 FROM halachot "
                "WHERE case_law_id = $1", case_law_id,
            )
            for p in principles:
                voters = ",".join(p.get("voters") or [])
                reviewer = f"panel:{voters} v{p.get('votes', 0)}/s{p.get('score', 0)}"
                approved = p.get("review_status") == "approved"
                emb = p.get("embedding")
                canonical_id = p.get("canonical_id")
                instance_type = p.get("instance_type", "original")
                idx = base + created_new + linked
                await conn.execute(
                    """INSERT INTO halachot
                       (case_law_id, halacha_index, rule_statement, rule_type,
                        reasoning_summary, supporting_quote, page_reference,
                        practice_areas, subject_tags, cites, confidence,
                        quote_verified, quality_flags, embedding, review_status,
                        reviewer, reviewed_at, canonical_id, instance_type)
                       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
                               CASE WHEN $17 THEN now() ELSE NULL END,$18,$19)""",
                    case_law_id, idx, p["rule_statement"],
                    p.get("rule_type", "interpretive"), p.get("reasoning_summary", ""),
                    p["supporting_quote"], p.get("page_reference", ""),
                    p.get("practice_areas", []), p.get("subject_tags", []),
                    p.get("cites", []), float(p.get("confidence", p.get("score", 0.0))),
                    p.get("quote_verified", False), p.get("quality_flags", []),
                    emb, p.get("review_status", "pending_review"), reviewer,
                    approved, canonical_id, instance_type,
                )
                if instance_type == "citation" and canonical_id is not None:
                    await conn.execute(
                        "UPDATE canonical_halachot SET "
                        "instance_count = instance_count + 1, updated_at = now() "
                        "WHERE id = $1", canonical_id,
                    )
                    linked += 1
                else:
                    new_canon_id = await conn.fetchval(
                        "INSERT INTO canonical_halachot "
                        "(canonical_statement, rule_type, practice_areas, subject_tags, "
                        " embedding, first_established_in, review_status, instance_count) "
                        "VALUES ($1,$2,$3,$4,$5,$6,'pending_synthesis',1) RETURNING id",
                        p.get("rule_statement") or "", p.get("rule_type", "interpretive"),
                        p.get("practice_areas") or [], p.get("subject_tags") or [],
                        emb, case_law_id,
                    )
                    await conn.execute(
                        "UPDATE halachot SET canonical_id=$1 WHERE case_law_id=$2 "
                        "AND halacha_index=$3", new_canon_id, case_law_id, idx,
                    )
                    created_new += 1
    logger.info("store_panel_principles: case_law=%s — %d new, %d linked",
                case_law_id, created_new, linked)
    return {"created_new": created_new, "linked": linked}


async def list_halachot(
    case_law_id: UUID | None = None,
    review_status: str | None = None,
    practice_area: str | None = None,
    instance_type: str | None = None,
    limit: int = 200,
    offset: int = 0,
    exclude_low_quality: bool = False,
    order_by_priority: bool = False,
    cluster: bool = False,
    include_equivalents: bool = False,
    include_panel_round: bool = False,
    search: str | None = None,
) -> list[dict]:
    """List halachot with optional triage controls (#84).

    exclude_low_quality — drop items carrying ANY quality_flag (application /
      truncated_quote / quote_unverified / non_decision / thin_restatement /
      nli_unsupported / near_duplicate). These belong in a 'needs extraction
      fix' bucket, not the chair's approve queue (#84.1).
    search — free-text locate within the queue (case_number / case_name /
      rule_statement, case-insensitive). Filters server-side so a pending
      halacha ranked below the display window is still reachable; without it
      the queue only ever shows the top ``limit`` by priority.
    order_by_priority — replace FIFO with an active-learning order (#84.3, #133/FU-3):
      panel-disagreement first (the panel SPLIT, then ran INCOMPLETE — the
      labels of highest learning value: the chair's call resolves a genuine
      ambiguity and feeds rubric distillation, FU-4), then negatively-treated,
      then most-uncertain (lowest confidence), then oldest. Uncertainty-sampling
      on the panel's real disagreement signal, not just extraction confidence.
    cluster — annotate each row with ``cluster_id`` + ``cluster_size`` (#84.2):
      same-precedent halachot within HALACHA_CLUSTER_COSINE form one group so
      the UI can collapse near-identical principles into a single review card.
    """
    pool = await get_pool()
    conditions = []
    params: list = []
    idx = 1
    if case_law_id is not None:
        conditions.append(f"h.case_law_id = ${idx}")
        params.append(case_law_id)
        idx += 1
    if review_status:
        conditions.append(f"h.review_status = ${idx}")
        params.append(review_status)
        idx += 1
    if practice_area:
        conditions.append(f"${idx} = ANY(h.practice_areas)")
        params.append(practice_area)
        idx += 1
    if instance_type:
        conditions.append(f"h.instance_type = ${idx}")
        params.append(instance_type)
        idx += 1
    if exclude_low_quality:
        # a clean item has an empty/NULL quality_flags array
        conditions.append("COALESCE(array_length(h.quality_flags, 1), 0) = 0")
    if search and search.strip():
        # locate by decision identity OR rule text — server-side so an item
        # ranked below the display window is still reachable (no client-only filter).
        conditions.append(
            f"(cl.case_number ILIKE ${idx} OR cl.case_name ILIKE ${idx} "
            f"OR h.rule_statement ILIKE ${idx})")
        params.append(f"%{search.strip()}%")
        idx += 1
    where_sql = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    # #133/FU-3: rank the panel's latest verdict so splits/incompletes — the
    # highest-value active-learning labels — float to the top of the queue.
    # 'split' (genuine 1-1 disagreement) before 'incomplete' (a judge failed,
    # less informative); unanimous rounds and not-yet-judged items share the
    # tail and keep the #84.3 ordering among themselves.
    order_sql = (
        "ORDER BY (CASE pr.verdict WHEN 'split' THEN 0 WHEN 'incomplete' THEN 1 "
        "ELSE 2 END) ASC, corroboration_negative DESC, "
        "h.confidence ASC NULLS LAST, h.created_at ASC"
        if order_by_priority
        else "ORDER BY h.case_law_id, h.halacha_index"
    )
    params.extend([limit, offset])
    sql = f"""
        SELECT h.id, h.case_law_id, h.halacha_index, h.rule_statement,
               h.rule_type, h.reasoning_summary, h.supporting_quote,
               h.page_reference, h.practice_areas, h.subject_tags,
               h.cites, h.confidence, h.quote_verified, h.quality_flags,
               h.review_status,
               h.reviewer, h.reviewed_at, h.created_at, h.updated_at,
               h.canonical_id, h.instance_type,
               ch.canonical_statement, ch.instance_count,
               cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
               cl.precedent_level,
               COALESCE(cor.corroboration_count, 0)::int AS corroboration_count,
               COALESCE(cor.corroboration_negative, false) AS corroboration_negative,
               pr.verdict AS panel_verdict
        FROM halachot h
        LEFT JOIN canonical_halachot ch ON ch.id = h.canonical_id
        LEFT JOIN case_law cl ON cl.id = h.case_law_id
        LEFT JOIN (
            SELECT halacha_id,
                   count(DISTINCT COALESCE(citing_case_law_id::text,
                         citing_decision_id::text, source_citation_id::text))
                     FILTER (WHERE treatment IN ('followed','explained'))
                     AS corroboration_count,
                   bool_or(treatment IN
                     ('distinguished','criticized','questioned','overruled'))
                     AS corroboration_negative
            FROM halacha_citation_corroboration
            GROUP BY halacha_id
        ) cor ON cor.halacha_id = h.id
        LEFT JOIN (
            SELECT DISTINCT ON (halacha_id) halacha_id, verdict
            FROM halacha_panel_rounds
            ORDER BY halacha_id, round_ts DESC
        ) pr ON pr.halacha_id = h.id
        {where_sql}
        {order_sql}
        LIMIT ${idx} OFFSET ${idx + 1}
    """
    rows = await pool.fetch(sql, *params)
    out = []
    for r in rows:
        d = dict(r)
        if d.get("decision_date") is not None:
            d["decision_date"] = d["decision_date"].isoformat()
        # authority is DERIVED from the source, never stored (INV-DM7)
        d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
        out.append(d)
    if cluster and out:
        await _annotate_clusters(pool, out)
    if include_equivalents and out:
        await _annotate_equivalents(pool, out)
    if include_panel_round and out:
        await _annotate_panel_rounds(pool, out)
    return out


async def count_halachot(
    review_status: str | None = None,
    practice_area: str | None = None,
    exclude_low_quality: bool = False,
    search: str | None = None,
) -> int:
    """Full count for the same filter ``list_halachot`` uses (sans limit/offset).

    Powers the queue's "showing N of TOTAL" note so the chair knows how many
    pending items sit beyond the display window. Mirrors the WHERE exactly —
    one source of truth, no parallel counting logic.
    """
    pool = await get_pool()
    conditions = []
    params: list = []
    idx = 1
    if review_status:
        conditions.append(f"h.review_status = ${idx}")
        params.append(review_status)
        idx += 1
    if practice_area:
        conditions.append(f"${idx} = ANY(h.practice_areas)")
        params.append(practice_area)
        idx += 1
    if exclude_low_quality:
        conditions.append("COALESCE(array_length(h.quality_flags, 1), 0) = 0")
    if search and search.strip():
        conditions.append(
            f"(cl.case_number ILIKE ${idx} OR cl.case_name ILIKE ${idx} "
            f"OR h.rule_statement ILIKE ${idx})")
        params.append(f"%{search.strip()}%")
        idx += 1
    where_sql = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    sql = f"""
        SELECT count(*)
        FROM halachot h
        LEFT JOIN case_law cl ON cl.id = h.case_law_id
        {where_sql}
    """
    return await pool.fetchval(sql, *params) or 0


async def _annotate_panel_rounds(pool, out: list[dict]) -> None:
    """Attach the LATEST 3-judge panel round to each row (#133/FU-2), display-only.

    Surfaces the panel's deliberation — each lineage's vote + rationale and the
    derived verdict — inside the chair's review card, so her decision (the gold
    label the loop learns from) is informed by *why* the judges split. Reads the
    capture table halacha_panel_rounds; never affects review_status (INV-G10)."""
    ids = [d["id"] for d in out]
    rows = await pool.fetch(
        "SELECT DISTINCT ON (halacha_id) halacha_id, question, verdict, "
        "applied_action, round_ts, claude_vote, claude_reason, deepseek_vote, "
        "deepseek_reason, gemini_vote, gemini_reason "
        "FROM halacha_panel_rounds WHERE halacha_id = ANY($1::uuid[]) "
        "ORDER BY halacha_id, round_ts DESC",
        ids,
    )
    by_id = {
        str(r["halacha_id"]): {
            "question": r["question"],
            "verdict": r["verdict"],
            "applied_action": r["applied_action"],
            "round_ts": r["round_ts"].isoformat() if r["round_ts"] else None,
            "judges": [
                {"model": "claude", "vote": r["claude_vote"], "reason": r["claude_reason"]},
                {"model": "deepseek", "vote": r["deepseek_vote"], "reason": r["deepseek_reason"]},
                {"model": "gemini", "vote": r["gemini_vote"], "reason": r["gemini_reason"]},
            ],
        }
        for r in rows
    }
    for d in out:
        pr = by_id.get(str(d["id"]))
        if pr:
            d["panel_round"] = pr


async def _annotate_clusters(pool, out: list[dict]) -> None:
    """Add cluster_id + cluster_size to each row (#84.2), display-only.

    Same-precedent halachot within HALACHA_CLUSTER_COSINE are unioned into one
    group. Singletons get their own id as cluster_id and size 1. Pairwise is
    confined to the returned set (cheap; the queue is ~hundreds of rows)."""
    ids = [d["id"] for d in out]
    max_dist = 1.0 - config.HALACHA_CLUSTER_COSINE
    pairs = await pool.fetch(
        "SELECT a.id AS a, b.id AS b FROM halachot a JOIN halachot b "
        "ON a.case_law_id = b.case_law_id AND a.id < b.id "
        "AND a.embedding IS NOT NULL AND b.embedding IS NOT NULL "
        "AND (a.embedding <=> b.embedding) <= $2 "
        "WHERE a.id = ANY($1::uuid[]) AND b.id = ANY($1::uuid[])",
        ids, max_dist,
    )
    parent = {str(i): str(i) for i in ids}

    def find(x: str) -> str:
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    for p in pairs:
        ra, rb = find(str(p["a"])), find(str(p["b"]))
        if ra != rb:
            parent[ra] = rb
    from collections import Counter
    sizes = Counter(find(str(i)) for i in ids)
    for d in out:
        root = find(str(d["id"]))
        d["cluster_id"] = root
        d["cluster_size"] = sizes[root]


async def update_halacha(
    halacha_id: UUID,
    review_status: str | None = None,
    reviewer: str = "",
    rule_statement: str | None = None,
    reasoning_summary: str | None = None,
    subject_tags: list[str] | None = None,
    practice_areas: list[str] | None = None,
    supporting_quote: str | None = None,
) -> dict | None:
    """Update a halacha — used by the chair to approve/reject/edit.

    #133 follow-up — extraction repair: when ``supporting_quote`` is edited it is
    RE-VERIFIED against the stored source text (deterministic; no re-OCR / no LLM —
    see feedback_no_reocr_retrofit) and ``quote_verified`` + the ``quote_unverified``
    quality flag are synced. A chair who pastes the correct quote from the source
    clears the flag, and the item leaves the 'fix' bucket."""
    pool = await get_pool()
    set_parts: list[str] = []
    params: list = [halacha_id]
    idx = 2
    if supporting_quote is not None:
        set_parts.append(f"supporting_quote = ${idx}")
        params.append(supporting_quote)
        idx += 1
        # re-verify against the stored full_text, sync quote_verified + the flag
        src = await pool.fetchrow(
            "SELECT cl.full_text, h.quality_flags FROM halachot h "
            "JOIN case_law cl ON cl.id = h.case_law_id WHERE h.id = $1", halacha_id)
        if src is not None:
            from legal_mcp.services.halacha_extractor import _verify_quote
            verified = _verify_quote(supporting_quote, src["full_text"] or "")
            set_parts.append(f"quote_verified = ${idx}")
            params.append(verified)
            idx += 1
            flags = [f for f in (src["quality_flags"] or [])
                     if f != halacha_quality.FLAG_QUOTE_UNVERIFIED]
            if not verified:
                flags.append(halacha_quality.FLAG_QUOTE_UNVERIFIED)
            set_parts.append(f"quality_flags = ${idx}")
            params.append(flags)
            idx += 1
    if review_status is not None:
        set_parts.append(f"review_status = ${idx}")
        params.append(review_status)
        idx += 1
        if review_status in ("approved", "rejected", "published", "deferred"):
            set_parts.append(f"reviewed_at = now()")
            set_parts.append(f"reviewer = ${idx}")
            params.append(reviewer)
            idx += 1
    if rule_statement is not None:
        set_parts.append(f"rule_statement = ${idx}")
        params.append(rule_statement)
        idx += 1
    if reasoning_summary is not None:
        set_parts.append(f"reasoning_summary = ${idx}")
        params.append(reasoning_summary)
        idx += 1
    if subject_tags is not None:
        set_parts.append(f"subject_tags = ${idx}")
        params.append(subject_tags)
        idx += 1
    if practice_areas is not None:
        set_parts.append(f"practice_areas = ${idx}")
        params.append(practice_areas)
        idx += 1
    if not set_parts:
        return None
    set_parts.append("updated_at = now()")
    # Exclude `embedding` — it's a numpy.ndarray of np.float32 that breaks
    # FastAPI's jsonable_encoder downstream (PATCH /api/halachot/{id}).
    # Callers that need it (none today) can re-fetch with get_halacha.
    sql = f"""
        UPDATE halachot SET {', '.join(set_parts)} WHERE id = $1
        RETURNING id, case_law_id, halacha_index, rule_statement, rule_type,
                  reasoning_summary, supporting_quote, page_reference,
                  practice_areas, subject_tags, cites, confidence,
                  quote_verified, quality_flags, review_status, reviewer,
                  reviewed_at, created_at, updated_at
    """
    row = await pool.fetchrow(sql, *params)
    # FU-2 (#133): a firm chair decision on a panel-adjudicated halacha mints an
    # active-learning seed. Capture-only — never blocks the gate (errors logged).
    if row and review_status is not None:
        await seed_goldset_from_chair(halacha_id, review_status, reviewer)
    return dict(row) if row else None


# Statuses the chair can set via review (batch or single). 'deferred' = snooze:
# stays out of the active library AND out of the default pending queue, without
# the finality of 'rejected'. #84 review-queue triage.
HALACHA_REVIEW_STATUSES = {
    "pending_review", "approved", "rejected", "published", "deferred",
}


async def update_halachot_batch(
    halacha_ids: list[str], review_status: str, reviewer: str = "",
) -> int:
    """Bulk-set review_status for many halachot in one atomic statement.

    Powers the #84 "approve/reject/defer the whole group" action — one request,
    one transaction, one refetch (vs N PATCH round-trips). Only the status +
    reviewer + reviewed_at are touched (no content edits in batch). Returns the
    number of rows updated.
    """
    if not halacha_ids or review_status not in HALACHA_REVIEW_STATUSES:
        return 0
    ids = [UUID(str(i)) for i in halacha_ids]
    stamp = review_status in ("approved", "rejected", "published", "deferred")
    pool = await get_pool()
    result = await pool.execute(
        f"""UPDATE halachot
            SET review_status = $2,
                updated_at = now()
                {", reviewed_at = now(), reviewer = $3" if stamp else ""}
            WHERE id = ANY($1::uuid[])""",
        ids, review_status, *( [reviewer] if stamp else [] ),
    )
    # FU-2 (#133): mint an active-learning seed for each panel-adjudicated
    # halacha in the group. seed_goldset_from_chair is idempotent, self-guards
    # on a prior panel round, and never raises.
    if stamp:
        for hid in ids:
            await seed_goldset_from_chair(hid, review_status, reviewer)
    try:
        return int(result.split()[-1])
    except (ValueError, IndexError):
        return 0


async def approve_halacha_by_corroboration(
    halacha_id: UUID, n_sources: int, min_cites: int,
) -> bool:
    """Approve a halacha on citation corroboration — ONLY if it is currently
    awaiting the chair (``pending_review``). Never touches ``published`` /
    ``rejected`` / already-``approved`` (INV-COR5: the chair gate is preserved for
    everything else). The reviewer records the corroboration basis as provenance
    (INV-COR6). Returns True iff a row actually transitioned."""
    pool = await get_pool()
    reviewer = f"corroborated ({n_sources} judicial citations ≥ {min_cites})"
    row = await pool.fetchrow(
        "UPDATE halachot SET review_status='approved', reviewer=$2, "
        "reviewed_at=now(), updated_at=now() "
        "WHERE id=$1 AND review_status='pending_review' RETURNING id",
        halacha_id, reviewer,
    )
    return row is not None


async def demote_halacha_overruled(halacha_id: UUID) -> bool:
    """Demote an APPROVED halacha back to the chair gate because a later citing
    court overruled it (INV-COR2). Acts only on ``approved`` → ``pending_review``;
    leaves ``published`` / ``rejected`` / already-``pending_review`` untouched. The
    reviewer note records why it re-entered the queue. Returns True iff a row
    transitioned."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "UPDATE halachot SET review_status='pending_review', "
        "reviewer='flagged: overruled by later citation (X11)', "
        "reviewed_at=NULL, updated_at=now() "
        "WHERE id=$1 AND review_status='approved' RETURNING id",
        halacha_id,
    )
    return row is not None


async def list_corroboration_grouped(case_law_id: UUID) -> dict[str, list[dict]]:
    """Per-halacha corroboration links for a cited precedent, in the
    ``{source_id, treatment}`` shape ``aggregate()`` consumes. The distinct citing
    source is keyed by case_law/decision id (falling back to the citation row id
    so two anonymous rows are not collapsed)."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT hcc.halacha_id::text AS halacha_id, "
        "  COALESCE(hcc.citing_case_law_id::text, hcc.citing_decision_id::text, "
        "           hcc.source_citation_id::text) AS source_id, "
        "  hcc.treatment "
        "FROM halacha_citation_corroboration hcc "
        "JOIN halachot h ON h.id = hcc.halacha_id "
        "WHERE h.case_law_id = $1",
        case_law_id,
    )
    out: dict[str, list[dict]] = {}
    for r in rows:
        out.setdefault(r["halacha_id"], []).append(
            {"source_id": r["source_id"], "treatment": r["treatment"]}
        )
    return out


async def precedents_with_halachot_and_incoming_citations() -> list[str]:
    """case_law ids that have at least one halacha AND at least one incoming
    citation (either graph) — the corroboration backfill target set."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT c.id::text FROM case_law c "
        "WHERE EXISTS (SELECT 1 FROM halachot h WHERE h.case_law_id=c.id) "
        "  AND (EXISTS (SELECT 1 FROM precedent_internal_citations p "
        "               WHERE p.cited_case_law_id=c.id) "
        "    OR EXISTS (SELECT 1 FROM case_law_citations cc "
        "               WHERE cc.case_law_id=c.id))",
    )
    return [r["id"] for r in rows]


async def nearest_halacha_for_vector(case_law_id: UUID, vec: list[float]) -> tuple[str, float] | None:
    """Best-matching halacha of `case_law_id` for a context embedding (cosine)."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text AS id, 1 - (embedding <=> $2) AS sim "
        "FROM halachot WHERE case_law_id = $1 AND embedding IS NOT NULL "
        "ORDER BY embedding <=> $2 LIMIT 1",
        case_law_id, vec,
    )
    return (row["id"], float(row["sim"])) if row else None


async def incoming_citations_for_precedent(case_law_id: UUID) -> list[dict]:
    """All incoming citations (both graphs) with their context + source id."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT id::text AS source_id, source_case_law_id::text AS citing_case_law_id, "
        "       NULL::text AS citing_decision_id, match_context AS context "
        "FROM precedent_internal_citations WHERE cited_case_law_id = $1 "
        "UNION ALL "
        "SELECT id::text, NULL, decision_id::text, context_text "
        "FROM case_law_citations WHERE case_law_id = $1",
        case_law_id,
    )
    return [dict(r) for r in rows]


async def store_corroboration(
    halacha_id: str,
    source_id: str,
    citing_case_law_id,
    citing_decision_id,
    treatment: str,
    score: float,
    context: str,
) -> None:
    from uuid import UUID as _UUID
    pool = await get_pool()
    # asyncpg requires UUID objects for uuid-typed columns; convert non-None strings.
    h_id = _UUID(halacha_id) if isinstance(halacha_id, str) else halacha_id
    s_id = _UUID(source_id) if isinstance(source_id, str) else source_id
    cl_id = _UUID(citing_case_law_id) if (citing_case_law_id and isinstance(citing_case_law_id, str)) else citing_case_law_id
    d_id = _UUID(citing_decision_id) if (citing_decision_id and isinstance(citing_decision_id, str)) else citing_decision_id
    # INSERT ... SELECT so we can pull canonical_id from halachot in one round-trip.
    # canonical_id is NULL until backfill_canonical_halachot.py runs; COALESCE keeps
    # existing canonical_id on conflict so a pre-backfill row is upgraded when the
    # same corroboration is re-stored post-backfill.
    await pool.execute(
        "INSERT INTO halacha_citation_corroboration "
        "(halacha_id, canonical_id, citing_case_law_id, citing_decision_id, "
        " source_citation_id, treatment, match_score, match_context) "
        "SELECT $1, h.canonical_id, $2, $3, $4, $5, $6, $7 "
        "FROM halachot h WHERE h.id = $1 "
        "ON CONFLICT (halacha_id, source_citation_id) DO UPDATE SET "
        "treatment=EXCLUDED.treatment, match_score=EXCLUDED.match_score, "
        "canonical_id=COALESCE(EXCLUDED.canonical_id, "
        "                      halacha_citation_corroboration.canonical_id)",
        h_id, cl_id, d_id, s_id, treatment, score, context,
    )


# ── Parallel-authority (equivalent halachot) — #84.2 follow-up ───────────────
#
# A NON-citation, symmetric link between halachot of different precedents that
# state the same principle. Kept entirely separate from the citation corroboration
# above so the citator's counts never include non-citation recurrences.

def _equiv_order(a: UUID, b: UUID) -> tuple[UUID, UUID]:
    """Canonical ordering (halacha_a < halacha_b) so the pair is symmetric+unique."""
    return (a, b) if str(a) < str(b) else (b, a)


async def link_equivalent_halachot(
    a: UUID, b: UUID, *, cosine: float = 0.0, note: str = "", created_by: str = "",
) -> bool:
    """[DEPRECATED since V41] Record a parallel-authority link in equivalent_halachot.

    The canonical_halachot model (V41) supersedes this table — cross-precedent
    equivalence is now expressed via halachot.canonical_id. This function is kept
    for historical callers only; no new code should call it. Use
    ``create_canonical_halacha`` + ``nearest_canonical_halacha`` instead.

    Idempotent (symmetric UNIQUE). Returns False and does nothing if a == b or
    the two belong to the SAME precedent."""
    import warnings
    warnings.warn(
        "link_equivalent_halachot is deprecated since V41 (canonical_halachot). "
        "Use create_canonical_halacha / nearest_canonical_halacha instead.",
        DeprecationWarning, stacklevel=2,
    )
    if a == b:
        return False
    pool = await get_pool()
    same = await pool.fetchval(
        "SELECT (SELECT case_law_id FROM halachot WHERE id=$1) "
        "     = (SELECT case_law_id FROM halachot WHERE id=$2)", a, b,
    )
    if same:
        return False
    lo, hi = _equiv_order(a, b)
    await pool.execute(
        "INSERT INTO equivalent_halachot (halacha_a, halacha_b, cosine, note, created_by) "
        "VALUES ($1,$2,$3,$4,$5) ON CONFLICT (halacha_a, halacha_b) DO UPDATE SET "
        "cosine=GREATEST(equivalent_halachot.cosine, EXCLUDED.cosine), "
        "note=COALESCE(NULLIF(EXCLUDED.note,''), equivalent_halachot.note)",
        lo, hi, round(float(cosine), 3), note, created_by,
    )
    return True


async def unlink_equivalent_halachot(a: UUID, b: UUID) -> bool:
    pool = await get_pool()
    lo, hi = _equiv_order(a, b)
    res = await pool.execute(
        "DELETE FROM equivalent_halachot WHERE halacha_a=$1 AND halacha_b=$2", lo, hi,
    )
    return res.endswith(" 1")


async def list_equivalent_for_halacha(halacha_id: UUID) -> list[dict]:
    """The other halachot linked as parallel authority to this one (both sides)."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT e.cosine, h.id::text AS halacha_id, h.rule_statement, "
        "       cl.case_number, cl.case_name "
        "FROM equivalent_halachot e "
        "JOIN halachot h ON h.id = CASE WHEN e.halacha_a=$1 THEN e.halacha_b ELSE e.halacha_a END "
        "JOIN case_law cl ON cl.id = h.case_law_id "
        "WHERE e.halacha_a=$1 OR e.halacha_b=$1 "
        "ORDER BY e.cosine DESC", halacha_id,
    )
    return [
        {
            "halacha_id": r["halacha_id"],
            "rule_statement": r["rule_statement"],
            "case_number": r["case_number"],
            "case_name": r["case_name"],
            "cosine": float(r["cosine"]) if r["cosine"] is not None else None,
        }
        for r in rows
    ]


# ── Canonical halachot (V41) ─────────────────────────────────────────────────

async def create_canonical_halacha(
    statement: str,
    rule_type: str = "interpretive",
    practice_areas: list[str] | None = None,
    subject_tags: list[str] | None = None,
    embedding: list[float] | None = None,
    first_established_in: "UUID | None" = None,
    review_status: str = "pending_synthesis",
) -> "UUID":
    """Insert a new canonical principle and return its id."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "INSERT INTO canonical_halachot "
        "(canonical_statement, rule_type, practice_areas, subject_tags, "
        " embedding, first_established_in, review_status) "
        "VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id",
        statement,
        rule_type,
        practice_areas or [],
        subject_tags or [],
        embedding,
        first_established_in,
        review_status,
    )
    return row["id"]


async def nearest_canonical_halacha(
    vec: list[float],
    threshold: float = 0.85,
    status_filter: tuple[str, ...] = ("approved", "published"),
) -> "tuple[str, float] | None":
    """Return (canonical_id, cosine_sim) of the nearest approved/published canonical
    whose cosine similarity to `vec` meets `threshold`, or None if none qualifies.

    Used by the extractor's lookup-before-insert (Phase 3) to detect whether
    a newly extracted principle already exists in the registry.
    """
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text AS id, 1 - (embedding <=> $1) AS sim "
        "FROM canonical_halachot "
        "WHERE embedding IS NOT NULL AND review_status = ANY($2::text[]) "
        "ORDER BY embedding <=> $1 LIMIT 1",
        vec, list(status_filter),
    )
    if not row:
        return None
    sim = float(row["sim"])
    return (row["id"], sim) if sim >= threshold else None


async def refresh_canonical_instance_count(canonical_id: "UUID") -> None:
    """Recount halachot rows pointing to this canonical and update instance_count."""
    pool = await get_pool()
    await pool.execute(
        "UPDATE canonical_halachot SET "
        "instance_count = (SELECT COUNT(*) FROM halachot WHERE canonical_id = $1), "
        "updated_at = now() "
        "WHERE id = $1",
        canonical_id,
    )


async def get_canonical_halacha(canonical_id: "UUID") -> "dict | None":
    """Fetch one canonical principle with its instance list."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT ch.id::text, ch.canonical_statement, ch.rule_type, "
        "       ch.practice_areas, ch.subject_tags, ch.review_status, "
        "       ch.instance_count, ch.created_at, ch.updated_at, "
        "       cl.case_number AS first_established_case, "
        "       cl.source_kind, cl.is_binding "
        "FROM canonical_halachot ch "
        "LEFT JOIN case_law cl ON cl.id = ch.first_established_in "
        "WHERE ch.id = $1",
        canonical_id,
    )
    if not row:
        return None
    instances = await pool.fetch(
        "SELECT h.id::text, h.instance_type, h.treatment, h.supporting_quote, "
        "       h.page_reference, h.review_status AS instance_status, "
        "       cl.case_number, cl.case_name "
        "FROM halachot h JOIN case_law cl ON cl.id = h.case_law_id "
        "WHERE h.canonical_id = $1 ORDER BY h.instance_type, cl.case_number",
        canonical_id,
    )
    out = dict(row)
    # #152: source-derived class + Hebrew label (הלכה / כלל פרשני / עיקרון).
    out["principle_class"] = principles.principle_class(out.get("source_kind"), out.get("is_binding"))
    out["principle_label"] = principles.label_for_class(out["principle_class"])
    out["instances"] = [dict(i) for i in instances]
    return out


async def list_canonical_halachot(
    practice_area: str | None = None,
    review_status: str | None = None,
    limit: int = 50,
    offset: int = 0,
) -> list[dict]:
    """List canonical principles, optionally filtered by practice_area / review_status."""
    pool = await get_pool()
    conditions = ["1=1"]
    params: list = []
    idx = 1
    if practice_area:
        conditions.append(f"${ idx} = ANY(ch.practice_areas)")
        params.append(practice_area)
        idx += 1
    if review_status:
        conditions.append(f"ch.review_status = ${idx}")
        params.append(review_status)
        idx += 1
    params += [limit, offset]
    rows = await pool.fetch(
        f"SELECT ch.id::text, ch.canonical_statement, ch.rule_type, ch.practice_areas, "
        f"       ch.subject_tags, ch.review_status, ch.instance_count, "
        f"       ch.created_at, ch.updated_at, cl.source_kind, cl.is_binding "
        f"FROM canonical_halachot ch "
        f"LEFT JOIN case_law cl ON cl.id = ch.first_established_in "
        f"WHERE {' AND '.join(conditions)} "
        f"ORDER BY ch.instance_count DESC, ch.created_at DESC "
        f"LIMIT ${idx} OFFSET ${idx + 1}",
        *params,
    )
    out = []
    for r in rows:
        d = dict(r)
        cls = principles.principle_class(d.pop("source_kind", None), d.pop("is_binding", None))
        d["principle_class"] = cls
        d["principle_label"] = principles.label_for_class(cls)
        out.append(d)
    return out


async def update_canonical_statement(
    canonical_id: "UUID", canonical_statement: str,
) -> bool:
    """Update the synthesized statement of a canonical principle. Returns True if found."""
    pool = await get_pool()
    result = await pool.execute(
        "UPDATE canonical_halachot SET canonical_statement=$2, updated_at=now() "
        "WHERE id=$1",
        canonical_id, canonical_statement,
    )
    return result.split()[-1] != "0"


async def fetch_canonical_synthesis_input(canonical_id: "UUID") -> "dict | None":
    """Fetch everything the canonical-synthesis pass needs for one principle (V41 Phase 4).

    Unlike :func:`get_canonical_halacha` (UI-facing) this returns the canonical's own
    ``embedding`` (as a python list, for the drift guard) AND each instance's full text
    fields (``rule_statement`` + ``supporting_quote`` + ``reasoning_summary``) — the
    grounding evidence the LLM rewrites from (INV-AH). Returns None if not found.
    """
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text, canonical_statement, rule_type, practice_areas, "
        "       subject_tags, review_status, instance_count, embedding "
        "FROM canonical_halachot WHERE id=$1",
        canonical_id,
    )
    if not row:
        return None
    instances = await pool.fetch(
        "SELECT h.instance_type, h.treatment, h.rule_statement, "
        "       h.supporting_quote, h.reasoning_summary, "
        "       cl.case_number, cl.case_name "
        "FROM halachot h JOIN case_law cl ON cl.id = h.case_law_id "
        "WHERE h.canonical_id=$1 "
        "ORDER BY (h.instance_type='original') DESC, cl.case_number",
        canonical_id,
    )
    emb = row["embedding"]
    out = dict(row)
    out["embedding"] = list(emb) if emb is not None else None
    out["instances"] = [dict(i) for i in instances]
    return out


async def apply_canonical_synthesis(
    canonical_id: "UUID",
    canonical_statement: str,
    embedding: "list[float] | None" = None,
    review_status: str = "pending_review",
) -> bool:
    """Atomically commit a synthesis outcome for one canonical (V41 Phase 4).

    Always advances ``review_status`` (default → ``pending_review`` for the chair
    gate, G10/INV-LRN1) and writes ``canonical_statement``. ``embedding`` is updated
    only when provided (None = leave as-is) so the keep-original path on a
    drift-rejected/abstained synthesis doesn't need to re-embed. Returns True if the
    row existed.
    """
    pool = await get_pool()
    if embedding is None:
        result = await pool.execute(
            "UPDATE canonical_halachot "
            "SET canonical_statement=$2, review_status=$3, updated_at=now() "
            "WHERE id=$1",
            canonical_id, canonical_statement, review_status,
        )
    else:
        result = await pool.execute(
            "UPDATE canonical_halachot "
            "SET canonical_statement=$2, embedding=$3, review_status=$4, updated_at=now() "
            "WHERE id=$1",
            canonical_id, canonical_statement, embedding, review_status,
        )
    return result.split()[-1] != "0"


# ── Importance layer (#153) — principle-level gold matching ──────────────────

async def gold_chair_citations() -> list[dict]:
    """Committee-sourced citations for gold matching (#153).

    Each row: the cited precedent, the citing chair's name, and the match_context
    (text around the citation — the holding the chair invoked). `is_our_chair`
    distinguishes tier-1 (OUR_CHAIR → gold_chair) from tier-2 (other chair →
    chair_cited).
    """
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT pic.cited_case_law_id, pic.match_context, "
        "       COALESCE(src.chair_name,'') AS citing_chair, "
        "       (src.chair_name = $1) AS is_our_chair "
        "FROM precedent_internal_citations pic "
        "JOIN case_law src ON src.id = pic.source_case_law_id "
        "WHERE pic.cited_case_law_id IS NOT NULL "
        "  AND length(COALESCE(pic.match_context,'')) > 20",
        config.OUR_CHAIR_NAME,
    )
    return [dict(r) for r in rows]


async def gold_digest_holdings() -> list[dict]:
    """Digest→precedent links with the headline holding the digest highlights (#153)."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT linked_case_law_id, "
        "       COALESCE(NULLIF(headline_holding,''), summary, '') AS holding_text "
        "FROM digests "
        "WHERE linked_case_law_id IS NOT NULL "
        "  AND length(COALESCE(NULLIF(headline_holding,''), summary, '')) > 20",
    )
    return [dict(r) for r in rows]


async def nearest_original_principle(
    case_law_id: "UUID", vec: list[float],
) -> "tuple[str, float] | None":
    """Nearest live 'original' principle of a precedent to `vec`, with cosine sim.

    Scoped to one precedent (the cited/linked source) so a chair's citation is
    matched only against principles actually extracted from THAT decision. Skips
    rejected instances. Returns (halacha_id, sim) or None if the precedent has no
    embedded live principle.
    """
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT id::text AS id, 1 - (embedding <=> $2) AS sim "
        "FROM halachot "
        "WHERE case_law_id = $1 AND instance_type = 'original' "
        "  AND embedding IS NOT NULL AND review_status <> 'rejected' "
        "ORDER BY embedding <=> $2 LIMIT 1",
        case_law_id, vec,
    )
    return (row["id"], float(row["sim"])) if row else None


async def set_principle_gold(
    halacha_id: "UUID", *, gold_chair: bool = False, gold_digest: bool = False,
    chair_cited: bool = False, score: float | None = None,
) -> None:
    """OR-merge gold/chair-cited flags onto a principle (a principle may be both
    chair-cited AND in a digest). Keeps the MAX match score seen (#153)."""
    pool = await get_pool()
    await pool.execute(
        "UPDATE halachot SET "
        "  gold_chair = gold_chair OR $2, "
        "  gold_digest = gold_digest OR $3, "
        "  chair_cited = chair_cited OR $4, "
        "  gold_match_score = GREATEST(COALESCE(gold_match_score, 0), COALESCE($5, 0)), "
        "  updated_at = now() "
        "WHERE id = $1",
        halacha_id, gold_chair, gold_digest, chair_cited, score,
    )


async def reset_principle_gold() -> int:
    """Clear all gold/chair-cited flags (idempotent re-run of the matcher). #153."""
    pool = await get_pool()
    res = await pool.execute(
        "UPDATE halachot SET gold_chair=false, gold_digest=false, chair_cited=false, "
        "gold_match_score=NULL WHERE gold_chair OR gold_digest OR chair_cited",
    )
    return int(res.split()[-1]) if res.split()[-1].isdigit() else 0


async def gold_coverage_stats() -> dict:
    """Counts for the gold-matching coverage report (#153)."""
    pool = await get_pool()
    row = await pool.fetchrow(
        "SELECT "
        " count(*) FILTER (WHERE instance_type='original' AND review_status<>'rejected') AS live_original, "
        " count(*) FILTER (WHERE gold_chair) AS gold_chair, "
        " count(*) FILTER (WHERE gold_digest) AS gold_digest, "
        " count(*) FILTER (WHERE chair_cited) AS chair_cited, "
        " count(*) FILTER (WHERE gold_chair OR gold_digest) AS protected "
        "FROM halachot",
    )
    return dict(row)


async def refresh_verified_layer() -> dict:
    """Recompute the verified/cite_count layer from chair citations (#153).

    'verified' = the principle's SOURCE precedent was cited by a chair (any
    committee decision) WITHOUT a negative treatment. 'cite_count' = # distinct
    chair decisions citing it whose treatment is NOT negative (X11 §4 / INV-COR2:
    a precedent *distinguished*/*criticized*/*questioned*/*overruled* must never
    gain authority from those citations). Unclassified edges (treatment='') count
    as neutral-positive until ``classify_citation_treatments.py`` labels them, so
    the signal degrades gracefully before classification has run. This is the ONLY
    trust signal — never human review. Idempotent (full recompute).
    Returns {verified_principles, verified_precedents}.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "UPDATE halachot SET verified=false, cite_count=0 "
                "WHERE verified OR cite_count>0")
            await conn.execute(
                "WITH cc AS ("
                "  SELECT pic.cited_case_law_id AS id, "
                "         count(DISTINCT pic.source_case_law_id) AS n "
                "  FROM precedent_internal_citations pic "
                "  JOIN case_law src ON src.id = pic.source_case_law_id "
                "  WHERE src.source_kind='internal_committee' "
                "    AND pic.cited_case_law_id IS NOT NULL "
                "    AND coalesce(pic.treatment,'') NOT IN "
                "        ('distinguished','criticized','questioned','overruled') "
                "  GROUP BY pic.cited_case_law_id) "
                "UPDATE halachot h SET verified=true, cite_count=cc.n, updated_at=now() "
                "FROM cc WHERE h.case_law_id = cc.id")
            row = await conn.fetchrow(
                "SELECT count(*) FILTER (WHERE verified) AS vp, "
                "       count(DISTINCT case_law_id) FILTER (WHERE verified) AS vc "
                "FROM halachot")
    return {"verified_principles": row["vp"], "verified_precedents": row["vc"]}


# X11 §4 treatment buckets (mirrors corroboration.TREATMENT_POSITIVE/NEGATIVE) —
# kept here so the SQL layer can label a breakdown without importing the service.
_TREATMENT_POSITIVE = ("followed", "explained")
_TREATMENT_NEGATIVE = ("distinguished", "criticized", "questioned", "overruled")


async def citation_authority(case_law_ids: list["UUID"]) -> dict[str, dict]:
    """Per-precedent incoming-citation breakdown by treatment (X11 Phase 2, #154).

    For each precedent id → how many DISTINCT committee decisions cite it, split
    into positive (followed/explained), negative (distinguished/criticized/
    questioned/overruled) and unclassified (treatment not yet labelled). This is the
    'cited_by N (X אומצו, Y אובחנו)' authority signal surfaced to research agents so
    they can argue authority — and avoid leaning on a precedent that was repeatedly
    distinguished/overruled. Counts distinct sources; a source with no treatment yet
    falls in 'unclassified'. Returns {} for ids with no incoming committee citations.
    """
    if not case_law_ids:
        return {}
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT pic.cited_case_law_id::text AS id, "
        "       coalesce(NULLIF(pic.treatment, ''), 'unclassified') AS t, "
        "       count(DISTINCT pic.source_case_law_id) AS n "
        "FROM precedent_internal_citations pic "
        "JOIN case_law src ON src.id = pic.source_case_law_id "
        "WHERE src.source_kind = 'internal_committee' "
        "  AND pic.cited_case_law_id = ANY($1::uuid[]) "
        "GROUP BY 1, 2",
        case_law_ids,
    )
    out: dict[str, dict] = {}
    for r in rows:
        d = out.setdefault(r["id"], {
            "total": 0, "positive": 0, "negative": 0, "unclassified": 0,
            "by_treatment": {},
        })
        t, n = r["t"], int(r["n"])
        d["by_treatment"][t] = d["by_treatment"].get(t, 0) + n
        d["total"] += n
        if t in _TREATMENT_POSITIVE:
            d["positive"] += n
        elif t in _TREATMENT_NEGATIVE:
            d["negative"] += n
        else:
            d["unclassified"] += n
    return out


async def list_canonical_instances(canonical_id: "UUID") -> list[dict]:
    """List all halachot (instances) sharing a canonical_id — used by the UI accordion."""
    pool = await get_pool()
    rows = await pool.fetch(
        """SELECT h.id, h.instance_type, h.confidence, h.rule_statement,
                  cl.case_number, cl.case_name
           FROM halachot h
           LEFT JOIN case_law cl ON cl.id = h.case_law_id
           WHERE h.canonical_id = $1
           ORDER BY h.instance_type, cl.case_number""",
        canonical_id,
    )
    return [dict(r) for r in rows]


async def _annotate_equivalents(pool, out: list[dict]) -> None:
    """Attach an `equivalents` list to each row (#84.2) — parallel-authority links.

    Adds both directions, so when both halachot of a pair are on the same page
    each one lists the other."""
    ids = [d["id"] for d in out]
    rows = await pool.fetch(
        "SELECT e.halacha_a, e.halacha_b, e.cosine, "
        "       ha.rule_statement AS a_rule, cla.case_number AS a_case, "
        "       hb.rule_statement AS b_rule, clb.case_number AS b_case "
        "FROM equivalent_halachot e "
        "JOIN halachot ha ON ha.id = e.halacha_a "
        "JOIN case_law cla ON cla.id = ha.case_law_id "
        "JOIN halachot hb ON hb.id = e.halacha_b "
        "JOIN case_law clb ON clb.id = hb.case_law_id "
        "WHERE e.halacha_a = ANY($1::uuid[]) OR e.halacha_b = ANY($1::uuid[])",
        ids,
    )
    idset = {str(i) for i in ids}
    by_src: dict[str, list[dict]] = {}
    for r in rows:
        a, b = str(r["halacha_a"]), str(r["halacha_b"])
        cos = float(r["cosine"]) if r["cosine"] is not None else None
        if a in idset:
            by_src.setdefault(a, []).append({
                "halacha_id": b, "case_number": r["b_case"],
                "rule_statement": r["b_rule"], "cosine": cos})
        if b in idset:
            by_src.setdefault(b, []).append({
                "halacha_id": a, "case_number": r["a_case"],
                "rule_statement": r["a_rule"], "cosine": cos})
    for d in out:
        d["equivalents"] = by_src.get(str(d["id"]), [])


# ── Gold-set evaluation (#81.7 / #81.8) ──────────────────────────────────────

async def goldset_create_sample(
    n: int = 150, batch: str = "default", reset: bool = False,
) -> dict:
    """Stratified sample of halachot (round-robin over case×rule_type) into a
    tagging batch. Idempotent (ON CONFLICT); ``reset`` clears the batch first."""
    pool = await get_pool()
    if reset:
        await pool.execute("DELETE FROM halacha_goldset WHERE batch = $1", batch)
    rows = await pool.fetch(
        "SELECT id, case_law_id, rule_type FROM halachot WHERE rule_statement <> ''"
    )
    from collections import defaultdict
    buckets: dict = defaultdict(list)
    for r in rows:
        buckets[(r["case_law_id"], r["rule_type"])].append(r["id"])
    keys = list(buckets.values())
    sample: list = []
    i = 0
    while len(sample) < n and any(keys):
        b = keys[i % len(keys)]
        if b:
            sample.append(b.pop())
        i += 1
        if i > n * 50:
            break
    inserted = 0
    for hid in sample:
        res = await pool.execute(
            "INSERT INTO halacha_goldset (halacha_id, batch) VALUES ($1, $2) "
            "ON CONFLICT (halacha_id, batch) DO NOTHING", hid, batch,
        )
        if res.endswith(" 1"):
            inserted += 1
    total = await pool.fetchval(
        "SELECT count(*) FROM halacha_goldset WHERE batch = $1", batch)
    return {"batch": batch, "inserted": inserted, "total": total}


async def goldset_list(batch: str = "default") -> list[dict]:
    """Gold-set items joined with the halacha content + the machine's labels."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT g.id, g.halacha_id::text AS halacha_id, g.is_holding, "
        "       g.correct_type, g.quote_complete, g.tagged_by, g.tagged_at, "
        "       g.ai_is_holding, g.ai_correct_type, g.ai_rationale, g.ai_generated_at, "
        "       g.ds_is_holding, g.ds_correct_type, g.ds_rationale, "
        "       g.gm_is_holding, g.gm_correct_type, g.gm_rationale, "
        "       g.panel_agreement, g.anon_is_holding, g.anon_stable, g.panel_generated_at, "
        "       h.rule_statement, h.supporting_quote, h.reasoning_summary, "
        "       h.rule_type, h.confidence, h.quality_flags, h.review_status, "
        "       cl.case_number, cl.case_name, cl.source_type, cl.precedent_level "
        "FROM halacha_goldset g JOIN halachot h ON h.id = g.halacha_id "
        "LEFT JOIN case_law cl ON cl.id = h.case_law_id "
        "WHERE g.batch = $1 ORDER BY g.created_at, g.id", batch,
    )
    out = []
    for r in rows:
        d = dict(r)
        if d.get("tagged_at") is not None:
            d["tagged_at"] = d["tagged_at"].isoformat()
        if d.get("ai_generated_at") is not None:
            d["ai_generated_at"] = d["ai_generated_at"].isoformat()
        if d.get("panel_generated_at") is not None:
            d["panel_generated_at"] = d["panel_generated_at"].isoformat()
        if d.get("confidence") is not None:
            d["confidence"] = float(d["confidence"])
        # authority is DERIVED from the source, never stored (INV-DM7)
        d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
        out.append(d)
    return out


async def goldset_set_ai_recommendation(
    goldset_id: UUID, *, ai_is_holding: bool | None,
    ai_correct_type: str = "", ai_rationale: str = "",
) -> None:
    """Store the independent AI second-opinion for a gold-set item (QA aid)."""
    pool = await get_pool()
    await pool.execute(
        "UPDATE halacha_goldset SET ai_is_holding = $2, ai_correct_type = $3, "
        "ai_rationale = $4, ai_generated_at = now() WHERE id = $1",
        goldset_id, ai_is_holding, ai_correct_type, ai_rationale,
    )


async def goldset_set_panel_label(
    goldset_id: UUID, *,
    claude: dict | None, deepseek: dict | None, gemini: dict | None,
    consensus_is_holding: bool | None, consensus_type: str,
    agreement: str,
    anon_is_holding: bool | None = None, anon_stable: bool | None = None,
) -> None:
    """Store a tri-model panel labeling for one gold-set item (#81.7).

    Writes the three per-lineage votes (claude→ai_*, deepseek→ds_*, gemini→gm_*)
    and the agreement tag. When the panel reached a non-split consensus, ALSO
    writes it into the ground-truth columns (is_holding / correct_type) with
    tagged_by='panel:opus+deepseek+gemini' so ``goldset_score`` reads it
    unchanged. A SPLIT/incomplete vote leaves is_holding untouched (NULL →
    chair escalation, INV-G10). A prior human tag (tagged_by='chair') is never
    overwritten.

    Each per-model dict is ``{"is_holding": bool, "type": str, "rationale": str}``
    or None when that judge failed.
    """
    def _h(d):
        return bool(d["is_holding"]) if isinstance(d, dict) and "is_holding" in d else None

    def _t(d):
        return str(d.get("type") or "") if isinstance(d, dict) else ""

    def _r(d):
        return str(d.get("rationale") or "")[:300] if isinstance(d, dict) else ""

    pool = await get_pool()
    async with pool.acquire() as conn:
        async with conn.transaction():
            await conn.execute(
                "UPDATE halacha_goldset SET "
                "ai_is_holding=$2, ai_correct_type=$3, ai_rationale=$4, ai_generated_at=now(), "
                "ds_is_holding=$5, ds_correct_type=$6, ds_rationale=$7, "
                "gm_is_holding=$8, gm_correct_type=$9, gm_rationale=$10, "
                "panel_agreement=$11, anon_is_holding=$12, anon_stable=$13, "
                "panel_generated_at=now() WHERE id=$1",
                goldset_id,
                _h(claude), _t(claude), _r(claude),
                _h(deepseek), _t(deepseek), _r(deepseek),
                _h(gemini), _t(gemini), _r(gemini),
                agreement, anon_is_holding, anon_stable,
            )
            # Write the consensus into ground-truth ONLY on a real majority, and
            # never clobber a human tag.
            if consensus_is_holding is not None and agreement in ("3/3", "2/3"):
                await conn.execute(
                    "UPDATE halacha_goldset SET is_holding=$2, correct_type=$3, "
                    "tagged_by='panel:opus+deepseek+gemini', tagged_at=now() "
                    "WHERE id=$1 AND COALESCE(tagged_by,'') <> 'chair'",
                    goldset_id, consensus_is_holding, consensus_type,
                )


async def insert_panel_round(
    halacha_id: UUID, *, round_ts: datetime, question: str, bucket: str,
    claude: dict | None, deepseek: dict | None, gemini: dict | None,
    vote_key: str, verdict: str, applied_action: str = "", apply_mode: bool = False,
) -> None:
    """Persist ONE 3-judge panel adjudication of one halacha (#133 / FU-1).

    Capture-only: writes to halacha_panel_rounds and never touches `halachot`
    (the chair gate stays the single source of truth, INV-G10). Each per-model
    dict is the judge's raw JSON reply ({"<vote_key>": bool, "reason": str}) or
    None when that judge failed. vote_key is 'keep' (clean bucket) or 'entailed'
    (nli). round_ts is shared across a whole run so a round can be reconstructed.
    The learning seed is formed later by joining this against the chair's later
    decision on the same halacha.
    """
    def _v(d):
        if not isinstance(d, dict) or vote_key not in d:
            return None
        x = d[vote_key]
        return x if isinstance(x, bool) else str(x).strip().lower() in ("true", "1", "yes", "כן")

    def _r(d):
        return str(d.get("reason") or "")[:500] if isinstance(d, dict) else ""

    pool = await get_pool()
    await pool.execute(
        "INSERT INTO halacha_panel_rounds (halacha_id, round_ts, question, bucket, "
        "claude_vote, claude_reason, deepseek_vote, deepseek_reason, "
        "gemini_vote, gemini_reason, verdict, applied_action, apply_mode) "
        "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)",
        halacha_id, round_ts, question, bucket,
        _v(claude), _r(claude), _v(deepseek), _r(deepseek),
        _v(gemini), _r(gemini), verdict, applied_action, apply_mode,
    )


# The machine reviewers that DON'T represent a human ground-truth decision.
# A seed must never be minted from these (echo-chamber guard, #133). They use
# raw SQL today and never reach update_halacha, so this is defense-in-depth.
_MACHINE_REVIEWER_PREFIXES = ("panel:", "corroborated")

# Chair decisions that are a firm keep/drop judgment → map to the coarse
# is_holding gold-set axis ("is this a real, keepable rule?", the axis
# halacha_panel_calibrate.py measures against). 'deferred'/'pending_review'
# are a snooze, not a judgment → no seed.
_CHAIR_SEED_LABEL = {"approved": True, "published": True, "rejected": False}


def _chair_seed_label(review_status: str, reviewer: str = "") -> bool | None:
    """Pure gate for the FU-2 seed: the is_holding label a chair decision should
    mint, or None when NO seed is allowed — either a non-firm status
    (deferred/pending_review) or a machine reviewer (echo-chamber guard). Kept
    pure (no DB) so the guard is unit-testable offline."""
    is_holding = _CHAIR_SEED_LABEL.get(review_status)
    if is_holding is None:
        return None
    rev = (reviewer or "").strip().lower()
    if any(rev.startswith(p) for p in _MACHINE_REVIEWER_PREFIXES):
        return None
    return is_holding


async def seed_goldset_from_chair(
    halacha_id: UUID, review_status: str, reviewer: str = "",
) -> bool:
    """Active-learning seed at the existing chair gate (#133 / FU-2).

    When the chair makes a firm keep/drop decision on a halacha the panel
    ALREADY adjudicated, capture it as a chair-tagged gold-set label. This is
    the ONLY signal the active-learning loop is allowed to learn from — human
    ground-truth, never the panel's own votes (learning from the votes is an
    echo-chamber: agreement rises, accuracy doesn't, the panel drifts from the
    chair). The (panel votes ⋈ chair decision) pair feeds rubric distillation
    (FU-4) and measurement (FU-5).

    Capture-only and idempotent (UPSERT on the chair-live batch); it never
    touches `halachot` (the chair gate on /precedents stays the single source
    of truth, INV-G10) and never raises into the chair gate.

    Fires only when ALL hold:
      - review_status is a firm decision (approved/published → keep=True,
        rejected → drop=False); 'deferred'/'pending_review' are skipped.
      - the reviewer is human (machine reviewers excluded — defense-in-depth).
      - a prior halacha_panel_rounds row exists: the seed's value is the
        panel-vs-chair pair, so a halacha the panel never judged is skipped.

    Returns True iff a seed row was written/updated.
    """
    is_holding = _chair_seed_label(review_status, reviewer)
    if is_holding is None:
        return False
    try:
        pool = await get_pool()
        # Only seed halachot the panel previously adjudicated (FU-1 rounds).
        had_round = await pool.fetchval(
            "SELECT EXISTS(SELECT 1 FROM halacha_panel_rounds WHERE halacha_id = $1)",
            halacha_id,
        )
        if not had_round:
            return False
        await pool.execute(
            "INSERT INTO halacha_goldset (halacha_id, batch, is_holding, tagged_by, tagged_at) "
            "VALUES ($1, 'chair-live', $2, 'chair', now()) "
            "ON CONFLICT (halacha_id, batch) DO UPDATE "
            "SET is_holding = EXCLUDED.is_holding, tagged_by = 'chair', tagged_at = now()",
            halacha_id, is_holding,
        )
        return True
    except Exception as e:  # never let a learning seed break the chair gate
        logger.warning("FU-2 gold-set seed failed for halacha %s: %s", halacha_id, e)
        return False


async def panel_rounds_vs_chair(limit: int = 2000) -> list[dict]:
    """Read-only join for rubric distillation (#133 / FU-4).

    For every halacha that has BOTH a chair-live gold-set seed (FU-2 — the
    human keep/drop ground-truth) AND at least one captured panel round (FU-1),
    return the LATEST round's per-judge votes+reasons+verdict next to the
    chair's label and the halacha text. These (panel ⋈ chair) pairs are the
    ONLY signal the distillation may learn from — human ground-truth, never the
    panel's own votes (echo-chamber guard, INV-LRN1). Purely analytical: reads
    capture tables, writes nothing."""
    pool = await get_pool()
    rows = await pool.fetch(
        """
        SELECT g.halacha_id::text AS halacha_id,
               g.is_holding AS chair_keep, g.tagged_at AS chair_at,
               h.rule_statement, h.reasoning_summary, h.supporting_quote,
               h.rule_type, h.quality_flags,
               pr.question, pr.verdict, pr.applied_action, pr.round_ts,
               pr.claude_vote, pr.claude_reason,
               pr.deepseek_vote, pr.deepseek_reason,
               pr.gemini_vote, pr.gemini_reason
        FROM halacha_goldset g
        JOIN halachot h ON h.id = g.halacha_id
        JOIN LATERAL (
            SELECT * FROM halacha_panel_rounds r
            WHERE r.halacha_id = g.halacha_id
            ORDER BY r.round_ts DESC LIMIT 1
        ) pr ON true
        WHERE g.batch = 'chair-live' AND g.is_holding IS NOT NULL
        ORDER BY g.tagged_at DESC NULLS LAST
        LIMIT $1
        """,
        limit,
    )
    out = []
    for r in rows:
        d = dict(r)
        for k in ("chair_at", "round_ts"):
            if d.get(k) is not None:
                d[k] = d[k].isoformat()
        out.append(d)
    return out


async def goldset_tag(
    goldset_id: UUID, *, is_holding: bool | None = None,
    correct_type: str | None = None, quote_complete: bool | None = None,
    tagged_by: str = "chair",
) -> dict | None:
    """Save one human tag (partial — only provided fields change)."""
    pool = await get_pool()
    sets = ["tagged_by = $2", "tagged_at = now()"]
    params: list = [goldset_id, tagged_by]
    i = 3
    if is_holding is not None:
        sets.append(f"is_holding = ${i}"); params.append(is_holding); i += 1
    if correct_type is not None:
        sets.append(f"correct_type = ${i}"); params.append(correct_type); i += 1
    if quote_complete is not None:
        sets.append(f"quote_complete = ${i}"); params.append(quote_complete); i += 1
    row = await pool.fetchrow(
        f"UPDATE halacha_goldset SET {', '.join(sets)} WHERE id = $1 RETURNING *", *params,
    )
    return dict(row) if row else None


async def goldset_score(batch: str = "default") -> dict:
    """Measure each extraction validator against the gold-set labels (#81.8).

    Ground truth is the ``is_holding`` column — set either by the tri-model
    panel consensus (tagged_by='panel:…', #81.7) or by a human override
    (tagged_by='chair'). Split-vote items stay NULL and are excluded here, so
    the score reflects only items the panel (or chair) actually decided. A
    validator flag predicts "NOT a clean holding"; ground truth is
    is_holding == false. truncated_quote is scored against quote_complete."""
    items = await goldset_list(batch)
    labeled = [r for r in items if r.get("is_holding") is not None]
    from collections import defaultdict
    counters: dict = defaultdict(lambda: {"tp": 0, "fp": 0, "fn": 0, "tn": 0})

    def tally(name: str, predicted_bad: bool, truly_bad: bool) -> None:
        c = counters[name]
        key = ("tp" if truly_bad else "fp") if predicted_bad else ("fn" if truly_bad else "tn")
        c[key] += 1

    for r in labeled:
        rule = r.get("rule_statement") or ""
        quote = r.get("supporting_quote") or ""
        rtype = r.get("rule_type") or "interpretive"
        qc = r["quote_complete"] if r["quote_complete"] is not None else True
        truly_bad = r["is_holding"] is False
        flags = halacha_quality.compute_quality_flags(rule, quote, "", qc, rtype)
        tally("any_flag", bool(flags), truly_bad)
        tally("application", halacha_quality.FLAG_APPLICATION in flags, truly_bad)
        tally("non_decision", halacha_quality.FLAG_NON_DECISION in flags, truly_bad)
        tally("thin_restatement", halacha_quality.FLAG_THIN_RESTATEMENT in flags, truly_bad)
        tally("truncated_quote", halacha_quality.is_quote_truncated(quote), qc is False)

    def prf(c: dict) -> dict:
        p = c["tp"] / (c["tp"] + c["fp"]) if (c["tp"] + c["fp"]) else 0.0
        rec = c["tp"] / (c["tp"] + c["fn"]) if (c["tp"] + c["fn"]) else 0.0
        f1 = 2 * p * rec / (p + rec) if (p + rec) else 0.0
        return {"precision": round(p, 3), "recall": round(rec, 3), "f1": round(f1, 3), **c}

    return {
        "batch": batch, "total": len(items), "labeled": len(labeled),
        "validators": {name: prf(c) for name, c in counters.items()},
    }


async def goldset_calibrate(
    batch: str = "default", ground_truth: str = "chair",
    thresholds: tuple[float, ...] = (0.70, 0.75, 0.80, 0.85, 0.90, 0.95),
) -> dict:
    """Calibrate the halacha auto-approve gate against the gold-set (#81.8).

    Against the gold-set ``is_holding`` labels, measures:
      - confidence-threshold gate: for each T, precision (P[is_holding|conf≥T])
        and recall (share of true keeps approved) — calibrates
        ``HALACHA_AUTO_APPROVE_THRESHOLD``;
      - panel policies: precision + coverage of auto-approving on the stored
        tri-model votes (majority 2/3, unanimous 3/3).

    ``ground_truth='chair'`` scores against HUMAN labels only — the panel votes
    are an input to the panel-policy rows, so scoring them against the consensus
    they produced would be circular; the human labels are the independent truth.
    Returns a structured dict (INV-LRN3); no DB writes (read-only).
    """
    items = await goldset_list(batch)
    if ground_truth == "chair":
        rows = [r for r in items if r.get("tagged_by") == "chair" and r.get("is_holding") is not None]
    else:
        rows = [r for r in items if r.get("is_holding") is not None]
    conf_rows = [r for r in rows if r.get("confidence") is not None]
    pos = sum(1 for r in conf_rows if r["is_holding"])

    def _gate(t: float) -> dict:
        appr = [r for r in conf_rows if float(r["confidence"]) >= t]
        tp = sum(1 for r in appr if r["is_holding"])
        prec = tp / len(appr) if appr else 0.0
        rec = tp / pos if pos else 0.0
        return {"threshold": t, "approved": len(appr),
                "precision": round(prec, 3), "recall": round(rec, 3)}

    def _votes(r: dict) -> list[bool]:
        return [r[k] for k in ("ai_is_holding", "ds_is_holding", "gm_is_holding")
                if r.get(k) is not None]

    def _policy(unanimous: bool) -> dict:
        decided = approved = tp = 0
        for r in rows:
            v = _votes(r)
            if len(v) < (3 if unanimous else 2):
                continue
            keep = all(v) if unanimous else (sum(v) > len(v) - sum(v))
            decided += 1
            if keep:
                approved += 1
                tp += int(bool(r["is_holding"]))
        return {"approved": approved, "precision": round(tp / approved, 3) if approved else 0.0,
                "coverage": round(decided / len(rows), 3) if rows else 0.0}

    return {
        "batch": batch, "ground_truth": ground_truth,
        "n": len(conf_rows), "positives": pos, "negatives": len(conf_rows) - pos,
        "confidence_sweep": [_gate(t) for t in thresholds],
        "current_threshold": config.HALACHA_AUTO_APPROVE_THRESHOLD,
        "panel_majority_2of3": _policy(unanimous=False),
        "panel_unanimous_3of3": _policy(unanimous=True),
    }


async def list_corroboration_for_halacha(halacha_id: UUID) -> list[dict]:
    """Return all corroboration rows for one halacha, ordered by match_score DESC."""
    pool = await get_pool()
    rows = await pool.fetch(
        "SELECT treatment, match_score, match_context, citing_case_law_id::text, "
        "       citing_decision_id::text, created_at "
        "FROM halacha_citation_corroboration WHERE halacha_id = $1 "
        "ORDER BY match_score DESC", halacha_id,
    )
    return [
        {
            "treatment": r["treatment"],
            "match_score": float(r["match_score"]) if r["match_score"] is not None else None,
            "match_context": r["match_context"],
            "citing_case_law_id": r["citing_case_law_id"],
            "citing_decision_id": r["citing_decision_id"],
            "created_at": r["created_at"].isoformat() if r["created_at"] else None,
        }
        for r in rows
    ]


#: Accepted ``source_kind`` selectors. ``""``/``"all"`` mean *no filter* —
#: the whole corpus, court rulings and appeals-committee decisions alike.
_SOURCE_KIND_SELECTORS = frozenset(
    {"", "all", "all_committees", "external_upload", "internal_committee", "cited_only"}
)


def _source_kind_clause(source_kind: str, column_prefix: str = "") -> str:
    """Return the SQL predicate for a ``source_kind`` selector, or "" for none.

    Single definition for every caller (G2) — the selector vocabulary was
    previously restated at each search/list site, which is how the
    ``external_upload`` default silently hid 104 appeals-committee
    decisions from ``search_precedent_library`` (#232).

    ``column_prefix`` is the table alias plus dot (e.g. ``"cl."``) or "" when
    the query selects from ``case_law`` directly.

    Raises ValueError on an unknown selector rather than interpolating it —
    the value reaches SQL by f-string, so the whitelist is also what keeps
    that safe.
    """
    sk = (source_kind or "").strip()
    if sk not in _SOURCE_KIND_SELECTORS:
        raise ValueError(
            f"source_kind לא מוכר: {source_kind!r}. "
            f"ערכים חוקיים: {sorted(_SOURCE_KIND_SELECTORS - {''})} או '' (הכל)"
        )
    if sk in ("", "all"):
        return ""
    p = column_prefix
    if sk == "all_committees":
        return (
            f"({p}source_kind = 'internal_committee' OR "
            f"({p}source_kind = 'external_upload' AND {p}source_type = 'appeals_committee'))"
        )
    return f"{p}source_kind = '{sk}'"


async def search_precedent_library_semantic(
    query_embedding: list[float],
    practice_area: str = "",
    court: str = "",
    precedent_level: str = "",
    appeal_subtype: str = "",
    is_binding: bool | None = None,
    subject_tag: str = "",
    limit: int = 10,
    include_halachot: bool = True,
    source_kind: str = "",
    district: str = "",
    chair_name: str = "",
) -> list[dict]:
    """Semantic search over precedents, optionally filtered by source_kind.

    source_kind=''                   → the whole corpus (default, #232)
    source_kind='external_upload'    → court rulings only
    source_kind='internal_committee' → appeals-committee decisions only

    Returns merged halachot + chunks. Halachot are pre-distilled rules, so
    they get a small score boost. Only ``approved`` / ``published`` halachot
    are visible (per chair-review policy). Chunks are visible regardless
    of halacha review status.
    """
    pool = await get_pool()
    sk_clause = _source_kind_clause(source_kind, "cl.")
    halacha_filters = [
        "h.review_status <> 'rejected'",  # #153: include background; rank verified higher
        "cl.searchable = true",
    ]
    chunk_filters = ["cl.searchable = true"]
    if sk_clause:
        halacha_filters.append(sk_clause)
        chunk_filters.append(sk_clause)
    h_params: list = [query_embedding, limit]
    c_params: list = [query_embedding, limit]
    h_idx = 3
    c_idx = 3

    if practice_area:
        halacha_filters.append(f"${h_idx} = ANY(h.practice_areas)")
        h_params.append(practice_area)
        h_idx += 1
        chunk_filters.append(f"cl.practice_area = ${c_idx}")
        c_params.append(practice_area)
        c_idx += 1
    if court:
        halacha_filters.append(f"cl.court ILIKE ${h_idx}")
        h_params.append(f"%{court}%")
        h_idx += 1
        chunk_filters.append(f"cl.court ILIKE ${c_idx}")
        c_params.append(f"%{court}%")
        c_idx += 1
    if precedent_level:
        halacha_filters.append(f"cl.precedent_level = ${h_idx}")
        h_params.append(precedent_level)
        h_idx += 1
        chunk_filters.append(f"cl.precedent_level = ${c_idx}")
        c_params.append(precedent_level)
        c_idx += 1
    if appeal_subtype:
        halacha_filters.append(f"cl.appeal_subtype = ${h_idx}")
        h_params.append(appeal_subtype)
        h_idx += 1
        chunk_filters.append(f"cl.appeal_subtype = ${c_idx}")
        c_params.append(appeal_subtype)
        c_idx += 1
    if is_binding is not None:
        halacha_filters.append(f"cl.is_binding = ${h_idx}")
        h_params.append(is_binding)
        h_idx += 1
        chunk_filters.append(f"cl.is_binding = ${c_idx}")
        c_params.append(is_binding)
        c_idx += 1
    if subject_tag:
        halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
        h_params.append(subject_tag)
        h_idx += 1
    if district:
        halacha_filters.append(f"cl.district = ${h_idx}")
        h_params.append(district)
        h_idx += 1
        chunk_filters.append(f"cl.district = ${c_idx}")
        c_params.append(district)
        c_idx += 1
    if chair_name:
        halacha_filters.append(f"cl.chair_name = ${h_idx}")
        h_params.append(chair_name)
        h_idx += 1
        chunk_filters.append(f"cl.chair_name = ${c_idx}")
        c_params.append(chair_name)
        c_idx += 1

    # #153: verified (chair-cited) principles float above background.
    vboost = (f"(CASE WHEN h.verified THEN {config.HALACHA_VERIFIED_BOOST} ELSE 0 END "
              f"+ LEAST(h.cite_count, {config.HALACHA_CITE_BOOST_CAP}) * {config.HALACHA_CITE_BOOST_PER})")
    halacha_sql = f"""
        SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
               h.reasoning_summary, h.supporting_quote, h.page_reference,
               h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
               cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
               cl.precedent_level, cl.chair_name, cl.district,
               h.verified, h.cite_count,
               (1 - (h.embedding <=> $1)) + {vboost} AS score
        FROM halachot h
        JOIN case_law cl ON cl.id = h.case_law_id
        WHERE {' AND '.join(halacha_filters)}
          AND h.embedding IS NOT NULL
        ORDER BY score DESC
        LIMIT $2
    """

    # Parent-doc retrieval (V17 / TaskMaster #48): the LEFT JOIN
    # surfaces each chunk's parent_chunk's content alongside it. When
    # ``config.PARENT_DOC_RETRIEVAL_ENABLED`` is true *and* the row has
    # a non-null parent, the post-processing loop swaps in the parent's
    # content so the writer sees the broader passage instead of the
    # 300-token sliver that matched. Legacy rows (parent_chunk_id NULL)
    # are unaffected — the JOIN returns NULL parent_* and the swap is a
    # no-op. Index ``idx_precedent_chunks_role`` is not used here
    # intentionally: filtering on chunk_role='child' would exclude
    # legacy single-tier rows that default to 'child' but have no
    # parent; an embedding-IS-NOT-NULL filter is equivalent because
    # parents store NULL embeddings.
    chunk_sql = f"""
        SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
               pc.section_type, pc.page_number,
               pc.parent_chunk_id,
               parent.content AS parent_content,
               parent.section_type AS parent_section_type,
               parent.page_number AS parent_page_number,
               cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
               cl.precedent_level, cl.practice_area, cl.chair_name, cl.district,
               1 - (pc.embedding <=> $1) AS score
        FROM precedent_chunks pc
        JOIN case_law cl ON cl.id = pc.case_law_id
        LEFT JOIN precedent_chunks parent
            ON parent.id = pc.parent_chunk_id
        WHERE {' AND '.join(chunk_filters)}
          AND pc.embedding IS NOT NULL
          -- #55: exclude tiny fragment chunks (artifacts of pre-fix
          -- mid-sentence header splits) that carry no retrievable signal.
          AND length(trim(pc.content)) >= 50
        ORDER BY pc.embedding <=> $1
        LIMIT $2
    """

    results: list[dict] = []
    if include_halachot:
        rows = await pool.fetch(halacha_sql, *h_params)
        for r in rows:
            d = dict(r)
            if d.get("decision_date") is not None:
                d["decision_date"] = d["decision_date"].isoformat()
            # Dynamic rule-level boost: scales with extractor confidence
            # so high-conf halachot rank higher than low-conf ones.
            # conf=0.78 → +0.047, conf=0.90 → +0.054, conf=0.95 → +0.057
            # Calibrated so the average (≈0.85) stays at +0.05 (legacy value).
            _conf = float(d.get("confidence") or 0.0)
            d["score"] = float(d["score"]) + max(_conf * 0.06, 0.0)
            # Stable cosine-scale relevance, carried alongside ``score``.
            # ``score`` is the RANKING signal and downstream fusion overwrites it
            # with an RRF value (~0.008-0.02) whenever the lexical leg returns
            # rows — a different scale entirely. Anything that THRESHOLDS must
            # read ``relevance`` instead, which always means "cosine similarity
            # to the query" no matter which fusion stages ran. See
            # hybrid_search._merge_sem_lex.
            d["relevance"] = d["score"]
            d["type"] = "halacha"
            # authority is DERIVED from the source, never stored (INV-DM7)
            d["authority"] = halacha_quality.derive_authority(d.get("precedent_level"))
            results.append(d)

    rows = await pool.fetch(chunk_sql, *c_params)
    for r in rows:
        d = dict(r)
        if d.get("decision_date") is not None:
            d["decision_date"] = d["decision_date"].isoformat()
        d["score"] = float(d["score"])
        d["relevance"] = d["score"]  # cosine anchor — see the halacha branch above
        d["type"] = "passage"
        _maybe_swap_parent(d)
        results.append(d)

    results.sort(key=lambda x: x["score"], reverse=True)
    # Dedupe: when multiple child hits share the same parent, we'd
    # otherwise return duplicate parent content. Keep the highest-
    # scoring hit per parent (skip if parent swap disabled or row has
    # no parent — chunk_id alone remains unique).
    return _dedupe_by_parent(results, limit)


def _maybe_swap_parent(row: dict) -> None:
    """Promote parent content into ``content`` when the flag is on
    and the row has a non-NULL parent. Mutates ``row`` in place.

    Adds debug fields ``child_content`` / ``child_section_type`` /
    ``child_page_number`` so callers can see what originally matched.
    Strips the ``parent_*`` keys that come back from the LEFT JOIN —
    they're an implementation detail of the swap.
    """
    parent_content = row.pop("parent_content", None)
    parent_section = row.pop("parent_section_type", None)
    parent_page = row.pop("parent_page_number", None)
    if (
        config.PARENT_DOC_RETRIEVAL_ENABLED
        and row.get("parent_chunk_id") is not None
        and parent_content
    ):
        row["child_content"] = row.get("content")
        row["child_section_type"] = row.get("section_type")
        row["child_page_number"] = row.get("page_number")
        row["content"] = parent_content
        # Parent's section_type is authoritative for the swapped row
        # (children inherit from their parent, but a parent that spans
        # a boundary uses its first section's type — same convention).
        if parent_section:
            row["section_type"] = parent_section
        if parent_page is not None:
            row["page_number"] = parent_page
        row["parent_swap"] = True


def _dedupe_by_parent(rows: list[dict], limit: int) -> list[dict]:
    """When parent-doc swap is active, multiple children sharing a
    parent collapse to one parent row (the highest-scored child wins).
    Rows without a parent (legacy chunks, halachot) pass through
    unchanged.
    """
    if not config.PARENT_DOC_RETRIEVAL_ENABLED:
        return rows[:limit]
    seen_parents: set = set()
    out: list[dict] = []
    for r in rows:
        pid = r.get("parent_chunk_id")
        if pid and r.get("parent_swap"):
            if pid in seen_parents:
                continue
            seen_parents.add(pid)
        out.append(r)
        if len(out) >= limit:
            break
    return out


async def search_precedent_library_lexical(
    *,
    query: str,
    practice_area: str = "",
    court: str = "",
    precedent_level: str = "",
    appeal_subtype: str = "",
    is_binding: bool | None = None,
    subject_tag: str = "",
    source_kind: str = "",
    district: str = "",
    chair_name: str = "",
    limit: int = 30,
    include_halachot: bool = True,
) -> list[dict]:
    """Lexical (BM25-like) search via ``ts_rank_cd`` over ``content_tsv``
    and ``rule_tsv`` (V12 columns).

    Mirrors the filter set of :func:`search_precedent_library_semantic`
    so the two layers can be fused 1:1 by rank in
    :mod:`hybrid_search` via RRF.

    Why ``plainto_tsquery``: it accepts free-text input, lowercases, and
    AND-joins the terms — matches the bi-encoder's "all words contribute"
    assumption better than ``websearch_to_tsquery`` (which inserts ORs).
    Empty / stopword-only queries return zero rows (no error).

    Why ``ts_rank_cd``: cover density variant — rewards documents where
    the query terms appear close together (e.g. "1461/20 אנטרים" matches
    the same paragraph). Higher is more relevant.
    """
    if not (query or "").strip():
        return []

    pool = await get_pool()
    sk_clause = _source_kind_clause(source_kind, "cl.")
    halacha_filters = [
        "h.review_status <> 'rejected'",  # #153: include background; rank verified higher
        "cl.searchable = true",
    ]
    chunk_filters = ["cl.searchable = true"]
    if sk_clause:
        halacha_filters.append(sk_clause)
        chunk_filters.append(sk_clause)
    # $1 = query, $2 = limit. Filters append starting at $3.
    h_params: list = [query, limit]
    c_params: list = [query, limit]
    h_idx = 3
    c_idx = 3

    if practice_area:
        halacha_filters.append(f"${h_idx} = ANY(h.practice_areas)")
        h_params.append(practice_area)
        h_idx += 1
        chunk_filters.append(f"cl.practice_area = ${c_idx}")
        c_params.append(practice_area)
        c_idx += 1
    if court:
        halacha_filters.append(f"cl.court ILIKE ${h_idx}")
        h_params.append(f"%{court}%")
        h_idx += 1
        chunk_filters.append(f"cl.court ILIKE ${c_idx}")
        c_params.append(f"%{court}%")
        c_idx += 1
    if precedent_level:
        halacha_filters.append(f"cl.precedent_level = ${h_idx}")
        h_params.append(precedent_level)
        h_idx += 1
        chunk_filters.append(f"cl.precedent_level = ${c_idx}")
        c_params.append(precedent_level)
        c_idx += 1
    if appeal_subtype:
        halacha_filters.append(f"cl.appeal_subtype = ${h_idx}")
        h_params.append(appeal_subtype)
        h_idx += 1
        chunk_filters.append(f"cl.appeal_subtype = ${c_idx}")
        c_params.append(appeal_subtype)
        c_idx += 1
    if is_binding is not None:
        halacha_filters.append(f"cl.is_binding = ${h_idx}")
        h_params.append(is_binding)
        h_idx += 1
        chunk_filters.append(f"cl.is_binding = ${c_idx}")
        c_params.append(is_binding)
        c_idx += 1
    if subject_tag:
        halacha_filters.append(f"${h_idx} = ANY(h.subject_tags)")
        h_params.append(subject_tag)
        h_idx += 1
    if district:
        halacha_filters.append(f"cl.district = ${h_idx}")
        h_params.append(district)
        h_idx += 1
        chunk_filters.append(f"cl.district = ${c_idx}")
        c_params.append(district)
        c_idx += 1
    if chair_name:
        halacha_filters.append(f"cl.chair_name = ${h_idx}")
        h_params.append(chair_name)
        h_idx += 1
        chunk_filters.append(f"cl.chair_name = ${c_idx}")
        c_params.append(chair_name)
        c_idx += 1

    # #153: verified (chair-cited) principles float above background.
    vboost = (f"(CASE WHEN h.verified THEN {config.HALACHA_VERIFIED_BOOST} ELSE 0 END "
              f"+ LEAST(h.cite_count, {config.HALACHA_CITE_BOOST_CAP}) * {config.HALACHA_CITE_BOOST_PER})")
    halacha_sql = f"""
        SELECT h.id AS halacha_id, h.case_law_id, h.rule_statement,
               h.reasoning_summary, h.supporting_quote, h.page_reference,
               h.practice_areas, h.subject_tags, h.confidence, h.rule_type,
               cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
               cl.precedent_level, cl.chair_name, cl.district,
               h.verified, h.cite_count,
               GREATEST(
                   ts_rank_cd(h.rule_tsv, plainto_tsquery('simple', $1)),
                   ts_rank_cd(cl.meta_tsv, plainto_tsquery('simple', $1))
               )
               + CASE WHEN cl.meta_tsv @@ plainto_tsquery('simple', $1)
                      THEN 1.0 ELSE 0.0 END
               + {vboost} AS score
        FROM halachot h
        JOIN case_law cl ON cl.id = h.case_law_id
        WHERE {' AND '.join(halacha_filters)}
          AND (h.rule_tsv @@ plainto_tsquery('simple', $1)
               OR cl.meta_tsv @@ plainto_tsquery('simple', $1))
        ORDER BY score DESC
        LIMIT $2
    """

    # Parent-doc retrieval (V17) — same LEFT JOIN strategy as the
    # semantic side. The tsvector match still runs over the child's
    # ``content_tsv``; only the *returned* content is promoted to the
    # parent when the flag is on and a parent exists. See
    # :func:`search_precedent_library_semantic` for the rationale.
    # We intentionally restrict matching to chunks with an embedding
    # (i.e. children + legacy single-tier rows). Hierarchical parents
    # store NULL embeddings, so even though their ``content_tsv`` is
    # populated they're excluded here — preventing a parent from
    # matching directly and then being "swapped" with itself.
    chunk_sql = f"""
        SELECT pc.id AS chunk_id, pc.case_law_id, pc.content,
               pc.section_type, pc.page_number,
               pc.parent_chunk_id,
               parent.content AS parent_content,
               parent.section_type AS parent_section_type,
               parent.page_number AS parent_page_number,
               cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
               cl.precedent_level, cl.practice_area, cl.chair_name, cl.district,
               GREATEST(
                   ts_rank_cd(pc.content_tsv, plainto_tsquery('simple', $1)),
                   ts_rank_cd(cl.meta_tsv, plainto_tsquery('simple', $1))
               )
               + CASE WHEN cl.meta_tsv @@ plainto_tsquery('simple', $1)
                      THEN 1.0 ELSE 0.0 END AS score
        FROM precedent_chunks pc
        JOIN case_law cl ON cl.id = pc.case_law_id
        LEFT JOIN precedent_chunks parent
            ON parent.id = pc.parent_chunk_id
        WHERE {' AND '.join(chunk_filters)}
          AND pc.embedding IS NOT NULL
          -- #55: exclude tiny fragment chunks (see semantic query above).
          AND length(trim(pc.content)) >= 50
          AND (pc.content_tsv @@ plainto_tsquery('simple', $1)
               OR cl.meta_tsv @@ plainto_tsquery('simple', $1))
        ORDER BY score DESC
        LIMIT $2
    """

    results: list[dict] = []
    if include_halachot:
        rows = await pool.fetch(halacha_sql, *h_params)
        for r in rows:
            d = dict(r)
            if d.get("decision_date") is not None:
                d["decision_date"] = d["decision_date"].isoformat()
            d["score"] = float(d["score"])
            d["type"] = "halacha"
            results.append(d)

    rows = await pool.fetch(chunk_sql, *c_params)
    for r in rows:
        d = dict(r)
        if d.get("decision_date") is not None:
            d["decision_date"] = d["decision_date"].isoformat()
        d["score"] = float(d["score"])
        d["type"] = "passage"
        _maybe_swap_parent(d)
        results.append(d)

    results.sort(key=lambda x: x["score"], reverse=True)
    return _dedupe_by_parent(results, limit)


async def precedent_library_stats() -> dict:
    """Aggregate stats for the /precedents stats tab."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        total = await conn.fetchval(
            "SELECT COUNT(*) FROM case_law"
        )
        by_practice = await conn.fetch(
            """SELECT practice_area, COUNT(*) AS n
               FROM case_law
               GROUP BY practice_area
               ORDER BY n DESC"""
        )
        by_level = await conn.fetch(
            """SELECT precedent_level, COUNT(*) AS n
               FROM case_law
               GROUP BY precedent_level
               ORDER BY n DESC"""
        )
        halachot_total = await conn.fetchval(
            "SELECT COUNT(*) FROM halachot"
        )
        halachot_pending = await conn.fetchval(
            "SELECT COUNT(*) FROM halachot WHERE review_status = 'pending_review'"
        )
        halachot_approved = await conn.fetchval(
            "SELECT COUNT(*) FROM halachot WHERE review_status IN ('approved', 'published')"
        )
        halachot_rejected = await conn.fetchval(
            "SELECT COUNT(*) FROM halachot WHERE review_status = 'rejected'"
        )
        halachot_deferred = await conn.fetchval(
            "SELECT COUNT(*) FROM halachot WHERE review_status = 'deferred'"
        )
    return {
        "precedents_total": int(total or 0),
        "by_practice_area": [
            {"practice_area": r["practice_area"], "count": int(r["n"])}
            for r in by_practice
        ],
        "by_precedent_level": [
            {"precedent_level": r["precedent_level"], "count": int(r["n"])}
            for r in by_level
        ],
        "halachot_total": int(halachot_total or 0),
        "halachot_pending": int(halachot_pending or 0),
        "halachot_approved": int(halachot_approved or 0),
        "halachot_rejected": int(halachot_rejected or 0),
        "halachot_deferred": int(halachot_deferred or 0),
    }


# ── V8: extraction request queue helpers ─────────────────────────


async def request_metadata_extraction(case_law_id: UUID) -> bool:
    """Stamp ``metadata_extraction_requested_at`` for the local MCP worker
    to pick up. Returns False if the row is missing.

    Originally restricted to ``source_kind='external_upload'`` (see git
    blame). Opened to all source kinds 2026-05-06 — internal_committee
    rows can also need re-extraction (e.g. corrupted subject_tags from
    an early ingest pipeline). The extractor itself preserves user
    values (``precedent_metadata_extractor.extract_and_apply`` only
    fills empty fields), so this is safe.
    """
    pool = await get_pool()
    # Reset the status to 'pending' alongside the timestamp so a re-request
    # after a prior 'completed'/'failed' run shows "בתור" again in the UI
    # instead of a stale terminal badge.
    result = await pool.execute(
        "UPDATE case_law SET metadata_extraction_requested_at = now(), "
        "metadata_extraction_status = 'pending' "
        "WHERE id = $1",
        case_law_id,
    )
    return result == "UPDATE 1"


async def request_halacha_extraction(case_law_id: UUID) -> bool:
    """Same but for halacha extraction. See note on
    :func:`request_metadata_extraction` re: opening to all source kinds.

    Sets ``halacha_extraction_status='pending'`` alongside the timestamp —
    symmetric to :func:`request_metadata_extraction` — so status and queue-stamp
    are written together and a re-request never leaves a stale terminal badge.
    This also closes the drift window where a row could end up 'pending' with a
    NULL requested_at (orphaned from the queue) (#139)."""
    pool = await get_pool()
    result = await pool.execute(
        "UPDATE case_law SET halacha_extraction_requested_at = now(), "
        "halacha_extraction_status = 'pending' "
        "WHERE id = $1",
        case_law_id,
    )
    return result == "UPDATE 1"


# Single source of truth for "this row can actually be extracted" (#140, G2):
# a real precedent whose text was chunked into precedent_chunks. ``cited_only``
# stubs are citation-only (no full_text, no chunks) and can NEVER yield an
# extraction, so they must never enter the work queue or get proactively
# re-queued. Shared by the queue reader (list_pending_extraction_requests) and
# the orphan-reconcile job (#139) so the eligibility rule lives in ONE place.
# Assumes the surrounding query exposes the table as ``case_law``.
EXTRACTION_ELIGIBLE_PREDICATE = (
    "case_law.source_kind <> 'cited_only' "
    "AND EXISTS (SELECT 1 FROM precedent_chunks pc WHERE pc.case_law_id = case_law.id)"
)


async def list_pending_extraction_requests(
    kind: str = "metadata",  # 'metadata' | 'halacha'
    limit: int = 20,
) -> list[dict]:
    """Return rows requesting extraction, oldest request first.

    The MCP worker drains the queue in order: process → clear timestamp.
    """
    col = (
        "metadata_extraction_requested_at"
        if kind == "metadata"
        else "halacha_extraction_requested_at"
    )
    pool = await get_pool()
    # Drop the legacy ``source_kind = 'external_upload'`` filter — without it
    # internal_committee rows could be stamped (we opened that gate in
    # request_metadata_extraction / request_halacha_extraction) but stayed
    # invisible to the worker forever.
    # Exclude ineligible rows (cited_only / chunkless) so a stub can never sit in
    # the work queue — same predicate the reconcile job uses (#140, G2).
    rows = await pool.fetch(
        f"""SELECT id, case_number, case_name, court, date,
                  practice_area, is_binding, {col} AS requested_at
            FROM case_law
            WHERE {col} IS NOT NULL
              AND {EXTRACTION_ELIGIBLE_PREDICATE}
            ORDER BY {col} ASC
            LIMIT $1""",
        limit,
    )
    out = []
    for r in rows:
        d = dict(r)
        if d.get("date") is not None:
            d["date"] = d["date"].isoformat()
        if d.get("requested_at") is not None:
            d["requested_at"] = d["requested_at"].isoformat()
        out.append(d)
    return out


async def requeue_stale_processing_extractions(kind: str = "halacha") -> int:
    """Re-stamp orphaned 'processing' rows so they re-drain. Returns count healed.

    A drain that died mid-extraction can leave a row ``status='processing'`` with
    its ``requested_at`` already cleared — orphaned: the queue selects on
    ``requested_at IS NOT NULL`` so it would never be picked again. We re-stamp
    those (only when requested_at IS NULL, i.e. not an actively-processing row in
    a concurrent run) so the next drain resumes them.
    """
    status_col = (
        "metadata_extraction_status" if kind == "metadata"
        else "halacha_extraction_status"
    )
    req_col = (
        "metadata_extraction_requested_at" if kind == "metadata"
        else "halacha_extraction_requested_at"
    )
    pool = await get_pool()
    tag = await pool.execute(
        f"UPDATE case_law SET {req_col} = now(), {status_col} = 'pending' "
        f"WHERE {status_col} = 'processing' AND {req_col} IS NULL"
    )
    try:
        return int(str(tag).split()[-1])
    except (ValueError, IndexError):
        return 0


async def reconcile_orphaned_pending_extractions(kind: str = "halacha") -> int:
    """Re-stamp eligible 'pending' rows that fell off the queue. Returns count.

    ``requeue_stale_processing_extractions`` heals only ``status='processing'``
    orphans. A row can also be ``status='pending'`` with ``requested_at IS NULL``
    — never enqueued (bulk/migration paths, or a status set before the stamp) —
    and the queue (``requested_at IS NOT NULL``) is blind to it forever, so the
    backlog drains silently to nothing (#139, INV-DUR1).

    This restores the "eligible row ⇒ in the queue" invariant: it stamps
    ``requested_at`` (and re-affirms 'pending') for rows that are pending,
    unstamped, and EXTRACTION-eligible — using the SAME
    ``EXTRACTION_ELIGIBLE_PREDICATE`` the queue reader uses (#140, G2), so
    cited_only / chunkless stubs are never proactively enqueued. kind-agnostic
    (metadata + halacha). Idempotent: a row already stamped is not matched, so
    concurrent runs converge on the same ``now()`` stamp harmlessly.
    """
    status_col = (
        "metadata_extraction_status" if kind == "metadata"
        else "halacha_extraction_status"
    )
    req_col = (
        "metadata_extraction_requested_at" if kind == "metadata"
        else "halacha_extraction_requested_at"
    )
    pool = await get_pool()
    tag = await pool.execute(
        f"UPDATE case_law SET {req_col} = now(), {status_col} = 'pending' "
        f"WHERE {status_col} = 'pending' AND {req_col} IS NULL "
        f"AND {EXTRACTION_ELIGIBLE_PREDICATE}"
    )
    try:
        return int(str(tag).split()[-1])
    except (ValueError, IndexError):
        return 0


async def extraction_queue_status() -> dict:
    """Pending-extraction queue depth per kind (INV-TOOL4 visibility / GAP-45).

    Surfaces the otherwise-hidden queue that ``process_pending_extractions``
    drains: how many case_law rows still carry a metadata/halacha extraction
    request, and the age of the oldest one. Read-only — does not drain.
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        meta = await conn.fetchrow(
            "SELECT COUNT(*) AS n, MIN(metadata_extraction_requested_at) AS oldest "
            "FROM case_law WHERE metadata_extraction_requested_at IS NOT NULL"
        )
        hal = await conn.fetchrow(
            "SELECT COUNT(*) AS n, MIN(halacha_extraction_requested_at) AS oldest "
            "FROM case_law WHERE halacha_extraction_requested_at IS NOT NULL"
        )

    def _fmt(r: dict) -> dict:
        oldest = r["oldest"]
        return {"pending": r["n"], "oldest_request": oldest.isoformat() if oldest else None}

    return {"metadata": _fmt(meta), "halacha": _fmt(hal)}


async def clear_extraction_request(
    case_law_id: UUID, kind: str = "metadata",
) -> None:
    col = (
        "metadata_extraction_requested_at"
        if kind == "metadata"
        else "halacha_extraction_requested_at"
    )
    pool = await get_pool()
    await pool.execute(
        f"UPDATE case_law SET {col} = NULL WHERE id = $1",
        case_law_id,
    )


# ── V9: Multimodal page image embeddings ─────────────────────────


async def store_document_image_embeddings(
    document_id: UUID,
    case_id: UUID | None,
    page_records: list[dict],
    model_name: str = "voyage-multimodal-3",
) -> int:
    """Replace per-page image embeddings for a document.

    Each ``page_records`` entry: ``{page_number, embedding, image_thumbnail_path}``.
    Embeddings should already be 1024-dim lists (or None for skipped pages).
    """
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "DELETE FROM document_image_embeddings WHERE document_id = $1",
            document_id,
        )
        for r in page_records:
            await conn.execute(
                """INSERT INTO document_image_embeddings
                   (document_id, case_id, page_number, embedding,
                    image_thumbnail_path, model_name)
                   VALUES ($1, $2, $3, $4, $5, $6)""",
                document_id, case_id,
                r["page_number"],
                r.get("embedding"),
                r.get("image_thumbnail_path"),
                model_name,
            )
    return len(page_records)


async def store_precedent_image_embeddings(
    case_law_id: UUID,
    page_records: list[dict],
    model_name: str = "voyage-multimodal-3",
) -> int:
    """Same pattern as store_document_image_embeddings but for precedents."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "DELETE FROM precedent_image_embeddings WHERE case_law_id = $1",
            case_law_id,
        )
        for r in page_records:
            await conn.execute(
                """INSERT INTO precedent_image_embeddings
                   (case_law_id, page_number, embedding,
                    image_thumbnail_path, model_name)
                   VALUES ($1, $2, $3, $4, $5)""",
                case_law_id,
                r["page_number"],
                r.get("embedding"),
                r.get("image_thumbnail_path"),
                model_name,
            )
    return len(page_records)


async def search_document_images_similar(
    query_embedding: list[float],
    limit: int = 10,
    case_id: UUID | None = None,
    practice_area: str | None = None,
    appeal_subtype: str | None = None,
) -> list[dict]:
    """Cosine search over per-page image embeddings of case documents."""
    pool = await get_pool()
    conditions: list[str] = []
    params: list = [query_embedding, limit]
    idx = 3
    if case_id:
        conditions.append(f"die.case_id = ${idx}")
        params.append(case_id); idx += 1
    if practice_area:
        conditions.append(f"c.practice_area = ${idx}")
        params.append(practice_area); idx += 1
    if appeal_subtype:
        conditions.append(f"c.appeal_subtype = ${idx}")
        params.append(appeal_subtype); idx += 1
    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    sql = f"""
        SELECT die.document_id, die.case_id, die.page_number,
               die.image_thumbnail_path,
               d.title AS document_title,
               c.case_number,
               1 - (die.embedding <=> $1) AS score
        FROM document_image_embeddings die
        JOIN documents d ON d.id = die.document_id
        JOIN cases c ON c.id = die.case_id
        {where}
        ORDER BY die.embedding <=> $1
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *params)
    return [dict(r) for r in rows]


async def search_precedent_images_similar(
    query_embedding: list[float],
    limit: int = 10,
    practice_area: str = "",
    court: str = "",
    precedent_level: str = "",
    appeal_subtype: str = "",
    is_binding: bool | None = None,
) -> list[dict]:
    """Cosine search over per-page image embeddings of precedent rulings."""
    pool = await get_pool()
    conditions: list[str] = ["cl.source_kind = 'external_upload'"]
    params: list = [query_embedding, limit]
    idx = 3
    if practice_area:
        conditions.append(f"cl.practice_area = ${idx}")
        params.append(practice_area); idx += 1
    if court:
        conditions.append(f"cl.court ILIKE ${idx}")
        params.append(f"%{court}%"); idx += 1
    if precedent_level:
        conditions.append(f"cl.precedent_level = ${idx}")
        params.append(precedent_level); idx += 1
    if appeal_subtype:
        conditions.append(f"cl.appeal_subtype = ${idx}")
        params.append(appeal_subtype); idx += 1
    if is_binding is not None:
        conditions.append(f"cl.is_binding = ${idx}")
        params.append(is_binding); idx += 1
    where = " AND ".join(conditions)
    sql = f"""
        SELECT pie.case_law_id, pie.page_number, pie.image_thumbnail_path,
               cl.case_number, cl.case_name, cl.court, cl.date AS decision_date,
               cl.precedent_level, cl.practice_area,
               1 - (pie.embedding <=> $1) AS score
        FROM precedent_image_embeddings pie
        JOIN case_law cl ON cl.id = pie.case_law_id
        WHERE {where}
        ORDER BY pie.embedding <=> $1
        LIMIT $2
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *params)
    out = []
    for r in rows:
        d = dict(r)
        if d.get("decision_date") is not None:
            d["decision_date"] = d["decision_date"].isoformat()
        out.append(d)
    return out


async def search_similar_hybrid(
    query_text_embedding: list[float],
    query_image_embedding: list[float],
    limit: int = 10,
    fetch_k: int = 30,
    text_weight: float = 0.65,
    case_id: UUID | None = None,
    section_type: str | None = None,
    practice_area: str | None = None,
    appeal_subtype: str | None = None,
) -> list[dict]:
    """Weighted merge of text-chunk and per-page image search.

    Same (document_id, page_number) → boost text chunk by image score
    on that page. Image-only pages with no overlapping text chunk are
    surfaced as ``match_type='image'`` so dense scanned content still
    appears in results.
    """
    img_weight = 1.0 - text_weight
    text_rows = await search_similar(
        query_text_embedding, limit=fetch_k, case_id=case_id,
        section_type=section_type, practice_area=practice_area,
        appeal_subtype=appeal_subtype,
    )
    img_rows = await search_document_images_similar(
        query_image_embedding, limit=fetch_k, case_id=case_id,
        practice_area=practice_area, appeal_subtype=appeal_subtype,
    )
    img_by_page: dict[tuple, dict] = {
        (str(r["document_id"]), r["page_number"]): r for r in img_rows
    }
    seen: set = set()
    merged: list[dict] = []
    for r in text_rows:
        page = r.get("page_number")
        key = (str(r["document_id"]), page) if page is not None else None
        img_hit = img_by_page.get(key) if key else None
        text_score = float(r["score"])
        image_score = float(img_hit["score"]) if img_hit else 0.0
        d = dict(r)
        d["text_score"] = text_score
        d["image_score"] = image_score
        d["score"] = text_score * text_weight + image_score * img_weight
        d["match_type"] = "text+image" if img_hit else "text"
        if img_hit:
            d["image_thumbnail_path"] = img_hit.get("image_thumbnail_path")
        merged.append(d)
        if key:
            seen.add(key)
    for r in img_rows:
        key = (str(r["document_id"]), r["page_number"])
        if key in seen:
            continue
        d = dict(r)
        d["text_score"] = 0.0
        d["image_score"] = float(r["score"])
        d["score"] = float(r["score"]) * img_weight
        d["match_type"] = "image"
        d["content"] = ""
        d["section_type"] = "image"
        merged.append(d)
    merged.sort(key=lambda x: -x["score"])
    return merged[:limit]


async def search_precedent_library_hybrid(
    query_text_embedding: list[float],
    query_image_embedding: list[float],
    limit: int = 10,
    fetch_k: int = 30,
    text_weight: float = 0.65,
    practice_area: str = "",
    court: str = "",
    precedent_level: str = "",
    appeal_subtype: str = "",
    is_binding: bool | None = None,
    subject_tag: str = "",
    include_halachot: bool = True,
) -> list[dict]:
    """Hybrid variant of search_precedent_library_semantic.

    Halachot have no ``page_number`` — they're boosted by the max
    image score from any page in the same case_law row.
    """
    img_weight = 1.0 - text_weight
    text_results = await search_precedent_library_semantic(
        query_text_embedding,
        practice_area=practice_area, court=court,
        precedent_level=precedent_level, appeal_subtype=appeal_subtype,
        is_binding=is_binding, subject_tag=subject_tag,
        limit=fetch_k, include_halachot=include_halachot,
    )
    img_results = await search_precedent_images_similar(
        query_image_embedding, limit=fetch_k,
        practice_area=practice_area, court=court,
        precedent_level=precedent_level, appeal_subtype=appeal_subtype,
        is_binding=is_binding,
    )
    img_by_page: dict[tuple, dict] = {}
    img_by_case: dict[str, float] = {}
    for r in img_results:
        cid = str(r["case_law_id"])
        img_by_page[(cid, r["page_number"])] = r
        img_by_case[cid] = max(img_by_case.get(cid, 0.0), float(r["score"]))
    seen: set = set()
    merged: list[dict] = []
    for r in text_results:
        cid = str(r["case_law_id"])
        page = r.get("page_number")
        key = (cid, page) if page is not None else None
        img_hit = img_by_page.get(key) if key else None
        if img_hit:
            image_score = float(img_hit["score"])
        elif r.get("type") == "halacha":
            image_score = img_by_case.get(cid, 0.0)
        else:
            image_score = 0.0
        text_score = float(r["score"])
        d = dict(r)
        d["text_score"] = text_score
        d["image_score"] = image_score
        d["score"] = text_score * text_weight + image_score * img_weight
        if img_hit:
            d["image_thumbnail_path"] = img_hit.get("image_thumbnail_path")
        if key:
            seen.add(key)
        merged.append(d)
    for r in img_results:
        key = (str(r["case_law_id"]), r["page_number"])
        if key in seen:
            continue
        d = dict(r)
        d["text_score"] = 0.0
        d["image_score"] = float(r["score"])
        d["score"] = float(r["score"]) * img_weight
        d["type"] = "image_page"
        d["content"] = ""
        d["section_type"] = "image"
        merged.append(d)
    merged.sort(key=lambda x: -x["score"])
    return merged[:limit]


# ── Missing precedents (V13) ───────────────────────────────────────
# Track citations from party briefs that aren't yet in the corpus.
# Lifecycle: 'open' → researcher logs gap → chair uploads decision
# → status='uploaded' (file ingested) → status='closed' (linked to
# case_law row). 'irrelevant' = chair decided the citation isn't worth
# adding to the library.

ALLOWED_MP_PARTIES = {
    "appellant", "respondent", "committee", "permit_applicant", "unknown",
}
ALLOWED_MP_STATUS = {"open", "uploaded", "closed", "irrelevant"}


def _row_to_missing_precedent(row: asyncpg.Record) -> dict:
    d = dict(row)
    d["id"] = str(d["id"])
    if d.get("cited_in_case_id") is not None:
        d["cited_in_case_id"] = str(d["cited_in_case_id"])
    if d.get("cited_in_document_id") is not None:
        d["cited_in_document_id"] = str(d["cited_in_document_id"])
    if d.get("linked_case_law_id") is not None:
        d["linked_case_law_id"] = str(d["linked_case_law_id"])
    return d


async def create_missing_precedent(
    citation: str,
    case_name: str | None = None,
    cited_in_case_id: UUID | None = None,
    cited_in_document_id: UUID | None = None,
    cited_by_party: str | None = None,
    cited_by_party_name: str | None = None,
    legal_topic: str | None = None,
    legal_issue: str | None = None,
    claim_quote: str | None = None,
    notes: str | None = None,
    discovery_source: str = "manual",
    linked_case_law_id: UUID | None = None,
) -> dict:
    """Create a new missing-precedent row (status='open' by default).

    ``citation_norm`` is computed at write-time (designator-aware, #143) so
    duplicate gaps for the same court+docket dedup correctly without merging the
    same docket across different courts. ``discovery_source`` records how the gap
    surfaced (manual | cited_only | digest | writer); ``linked_case_law_id`` may
    be set up-front when the canonical identity is already known (a cited_only
    stub awaiting its text) while ``status`` stays 'open' until the text lands.
    """
    if not citation.strip():
        raise ValueError("citation is required")
    if cited_by_party and cited_by_party not in ALLOWED_MP_PARTIES:
        raise ValueError(
            f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
        )
    citation_norm = court_citation.citation_dedup_key(citation)
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            """INSERT INTO missing_precedents (
                citation, case_name, cited_in_case_id, cited_in_document_id,
                cited_by_party, cited_by_party_name, legal_topic, legal_issue,
                claim_quote, notes, citation_norm, discovery_source,
                linked_case_law_id
            )
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
            RETURNING *""",
            citation.strip(), case_name, cited_in_case_id, cited_in_document_id,
            cited_by_party, cited_by_party_name, legal_topic, legal_issue,
            claim_quote, notes, citation_norm, discovery_source,
            linked_case_law_id,
        )
    return _row_to_missing_precedent(row)


# Provenance columns for the "צוטט ע״י" display (#148): for a non-appeal gap the
# cited_in_case_id is NULL, so we surface where it actually surfaced from —
# cited_by_precedents (corpus precedents that cite a cited_only stub, via the
# citation graph) and yomon_number (parsed from a digest gap's notes). Shared by
# the list and detail queries so both render identically (G2).
_MP_PROVENANCE_COLS = """,
               (SELECT array_agg(DISTINCT src.case_number ORDER BY src.case_number)
                  FROM precedent_internal_citations pic
                  JOIN case_law src ON src.id = pic.source_case_law_id
                  WHERE pic.cited_case_law_id = mp.linked_case_law_id
                    AND COALESCE(src.case_number, '') <> ''
               ) AS cited_by_precedents,
               substring(mp.notes from 'מס''?\\s*([0-9]+)') AS yomon_number,
               -- Bridge to the corpus citation graph (G2: read-time, no stored
               -- duplication). For an OPEN gap (no linked_case_law_id yet) find
               -- which committee DECISIONS cite this ruling, matched on the
               -- normalized docket number (same normalization the relinker uses:
               -- strip to digits/dashes, slash->dash). Surfaces "צוטט ע\"י <יו\"ר>"
               -- + the deciding case number in the UI.
               (SELECT array_agg(DISTINCT src.chair_name ORDER BY src.chair_name)
                  FROM precedent_internal_citations picc
                  JOIN case_law src ON src.id = picc.source_case_law_id
                       AND src.source_kind = 'internal_committee'
                  WHERE split_part(mp.citation_norm, '|', 2) <> ''
                    AND regexp_replace(replace(picc.cited_case_number, '/', '-'),
                                       '[^0-9-]', '', 'g')
                        = split_part(mp.citation_norm, '|', 2)
                    AND COALESCE(src.chair_name, '') <> ''
               ) AS cited_by_chairs,
               (SELECT array_agg(DISTINCT src.case_number ORDER BY src.case_number)
                  FROM precedent_internal_citations picd
                  JOIN case_law src ON src.id = picd.source_case_law_id
                       AND src.source_kind = 'internal_committee'
                  WHERE split_part(mp.citation_norm, '|', 2) <> ''
                    AND regexp_replace(replace(picd.cited_case_number, '/', '-'),
                                       '[^0-9-]', '', 'g')
                        = split_part(mp.citation_norm, '|', 2)
                    AND COALESCE(src.case_number, '') <> ''
               ) AS cited_by_decisions"""


async def list_missing_precedents(
    status: str | None = None,
    case_id: UUID | None = None,
    legal_topic: str | None = None,
    q: str | None = None,
    limit: int = 200,
    offset: int = 0,
) -> list[dict]:
    """List missing precedents, joining the cited-in case_number for display.

    ``q`` is a free-text term matched (ILIKE) across the gap's own מראה-מקום
    (``mp.citation``), its case name, and the cited-in appeal case number — so a
    chair can find a gap by the missing decision's number (e.g. ``85074``), not
    only by the appeal it was cited in."""
    pool = await get_pool()
    conditions: list[str] = []
    params: list = []
    idx = 1
    if status:
        conditions.append(f"mp.status = ${idx}")
        params.append(status)
        idx += 1
    if case_id:
        conditions.append(f"mp.cited_in_case_id = ${idx}")
        params.append(case_id)
        idx += 1
    if legal_topic:
        conditions.append(f"mp.legal_topic ILIKE ${idx}")
        params.append(f"%{legal_topic}%")
        idx += 1
    if q:
        conditions.append(
            f"(mp.citation ILIKE ${idx} OR mp.case_name ILIKE ${idx} "
            f"OR c.case_number ILIKE ${idx})"
        )
        params.append(f"%{q}%")
        idx += 1
    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    params.append(limit)
    params.append(offset)
    sql = f"""
        SELECT mp.*,
               c.case_number AS cited_in_case_number,
               cl.case_number AS linked_case_law_number,
               cl.case_name AS linked_case_law_name{_MP_PROVENANCE_COLS}
        FROM missing_precedents mp
        LEFT JOIN cases c ON c.id = mp.cited_in_case_id
        LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id
        {where}
        ORDER BY
            CASE mp.status
                WHEN 'open' THEN 0
                WHEN 'uploaded' THEN 1
                WHEN 'closed' THEN 2
                WHEN 'irrelevant' THEN 3
            END,
            mp.created_at DESC
        LIMIT ${idx} OFFSET ${idx + 1}
    """
    async with pool.acquire() as conn:
        rows = await conn.fetch(sql, *params)
    return [_row_to_missing_precedent(r) for r in rows]


async def get_missing_precedent(mp_id: UUID) -> dict | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            f"""
            SELECT mp.*,
                   c.case_number AS cited_in_case_number,
                   cl.case_number AS linked_case_law_number,
                   cl.case_name AS linked_case_law_name{_MP_PROVENANCE_COLS}
            FROM missing_precedents mp
            LEFT JOIN cases c ON c.id = mp.cited_in_case_id
            LEFT JOIN case_law cl ON cl.id = mp.linked_case_law_id
            WHERE mp.id = $1
            """,
            mp_id,
        )
    return _row_to_missing_precedent(row) if row else None


async def update_missing_precedent(mp_id: UUID, **fields) -> dict | None:
    """Patch a missing-precedent row. Allowed fields: legal_topic,
    legal_issue, notes, cited_by_party, cited_by_party_name, case_name,
    status, linked_case_law_id, closed_at."""
    if not fields:
        return await get_missing_precedent(mp_id)
    allowed = {
        "legal_topic", "legal_issue", "notes", "cited_by_party",
        "cited_by_party_name", "case_name", "status", "linked_case_law_id",
        "closed_at", "claim_quote", "citation",
    }
    clean = {k: v for k, v in fields.items() if k in allowed}
    if not clean:
        return await get_missing_precedent(mp_id)
    if "status" in clean and clean["status"] not in ALLOWED_MP_STATUS:
        raise ValueError(
            f"status must be one of {sorted(ALLOWED_MP_STATUS)}"
        )
    if "cited_by_party" in clean and clean["cited_by_party"] and \
            clean["cited_by_party"] not in ALLOWED_MP_PARTIES:
        raise ValueError(
            f"cited_by_party must be one of {sorted(ALLOWED_MP_PARTIES)}"
        )
    set_clauses = []
    values = []
    for i, (key, val) in enumerate(clean.items(), start=2):
        set_clauses.append(f"{key} = ${i}")
        values.append(val)
    set_clauses.append("updated_at = now()")
    sql = (
        f"UPDATE missing_precedents SET {', '.join(set_clauses)} "
        f"WHERE id = $1 RETURNING *"
    )
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(sql, mp_id, *values)
    return _row_to_missing_precedent(row) if row else None


async def close_missing_precedent(
    mp_id: UUID,
    linked_case_law_id: UUID | None = None,
    notes: str | None = None,
    status: str = "closed",
) -> dict | None:
    """Mark a missing-precedent row as closed (or 'uploaded'/'irrelevant')
    and link it to a case_law row if provided."""
    if status not in ALLOWED_MP_STATUS:
        raise ValueError(
            f"status must be one of {sorted(ALLOWED_MP_STATUS)}"
        )
    pool = await get_pool()
    async with pool.acquire() as conn:
        sets = ["status = $2", "closed_at = now()", "updated_at = now()"]
        params: list = [mp_id, status]
        idx = 3
        if linked_case_law_id is not None:
            sets.append(f"linked_case_law_id = ${idx}")
            params.append(linked_case_law_id)
            idx += 1
        if notes is not None:
            sets.append(f"notes = ${idx}")
            params.append(notes)
            idx += 1
        sql = (
            f"UPDATE missing_precedents SET {', '.join(sets)} "
            f"WHERE id = $1 RETURNING *"
        )
        row = await conn.fetchrow(sql, *params)
    return _row_to_missing_precedent(row) if row else None


async def find_missing_precedent_by_citation(
    citation: str,
    case_id: UUID | None = None,
) -> dict | None:
    """Look up an existing gap by DESIGNATOR-AWARE normalized citation and
    optionally cited-in case_id. Used to deduplicate auto-creation (researcher,
    cited_only derivation #143, digest derivation #136).

    Matches on ``citation_norm`` (``court|docket``) so formatting variants of the
    same court+docket collapse to one gap, while the same docket across different
    courts stays distinct. Falls back to an exact ``citation`` string match when
    the citation carries no parseable number (citation_norm is empty)."""
    norm = court_citation.citation_dedup_key(citation)
    pool = await get_pool()
    async with pool.acquire() as conn:
        if norm:
            match_col, match_val = "citation_norm", norm
        else:
            match_col, match_val = "citation", citation.strip()
        if case_id is not None:
            row = await conn.fetchrow(
                f"SELECT * FROM missing_precedents "
                f"WHERE {match_col} = $1 AND cited_in_case_id = $2 LIMIT 1",
                match_val, case_id,
            )
        else:
            row = await conn.fetchrow(
                f"SELECT * FROM missing_precedents WHERE {match_col} = $1 LIMIT 1",
                match_val,
            )
    return _row_to_missing_precedent(row) if row else None


# ── X13 — Court Verdict Fetch jobs ───────────────────────────────────────
# CRUD for the auto-fetch queue (docs/spec/X13-court-fetch.md). Status is
# always explicit; failures are recorded, never swallowed (INV-CF2). Upsert
# is keyed on the canonical case number (INV-CF5).

def _row_to_court_fetch_job(row) -> dict:
    return dict(row) if row else None


async def court_fetch_job_upsert(
    case_number_norm: str,
    citation_raw: str = "",
    tier: str = "",
    court: str = "",
    digest_id: UUID | None = None,
) -> dict:
    """Idempotent create-or-get of a fetch job by canonical case number.

    Re-requesting the same case number returns the existing row (with a
    ``_existing`` flag) rather than creating a duplicate — the canonical
    number is a UNIQUE key. A job that already reached a terminal state is
    returned as-is so callers can decide whether to retry.
    """
    if not (case_number_norm or "").strip():
        raise ValueError("case_number_norm is required")
    pool = await get_pool()
    async with pool.acquire() as conn:
        existing = await conn.fetchrow(
            "SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
            case_number_norm,
        )
        if existing:
            out = _row_to_court_fetch_job(existing)
            out["_existing"] = True
            return out
        row = await conn.fetchrow(
            """INSERT INTO court_fetch_jobs
                 (case_number_norm, citation_raw, tier, court, digest_id)
               VALUES ($1, $2, $3, $4, $5)
               RETURNING *""",
            case_number_norm, citation_raw, tier, court, digest_id,
        )
    out = _row_to_court_fetch_job(row)
    out["_existing"] = False
    return out


async def court_fetch_job_update(
    job_id: UUID,
    *,
    status: str | None = None,
    error: str | None = None,
    case_law_id: UUID | None = None,
    source_url: str | None = None,
    bump_attempts: bool = False,
) -> dict:
    """Patch a job row. Only provided fields change; ``updated_at`` always does."""
    sets = ["updated_at = now()"]
    args: list = []
    if status is not None:
        args.append(status); sets.append(f"status = ${len(args)}")
    if error is not None:
        args.append(error); sets.append(f"error = ${len(args)}")
    if case_law_id is not None:
        args.append(case_law_id); sets.append(f"case_law_id = ${len(args)}")
    if source_url is not None:
        args.append(source_url); sets.append(f"source_url = ${len(args)}")
    if bump_attempts:
        sets.append("attempts = attempts + 1")
    args.append(job_id)
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            f"UPDATE court_fetch_jobs SET {', '.join(sets)} "
            f"WHERE id = ${len(args)} RETURNING *",
            *args,
        )
    return _row_to_court_fetch_job(row)


async def court_fetch_job_get(case_number_norm: str) -> dict | None:
    pool = await get_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT * FROM court_fetch_jobs WHERE case_number_norm = $1",
            case_number_norm,
        )
    return _row_to_court_fetch_job(row) if row else None


async def court_fetch_job_list(status: str | None = None, limit: int = 100) -> list[dict]:
    pool = await get_pool()
    async with pool.acquire() as conn:
        if status:
            rows = await conn.fetch(
                "SELECT * FROM court_fetch_jobs WHERE status = $1 "
                "ORDER BY created_at DESC LIMIT $2",
                status, limit,
            )
        else:
            rows = await conn.fetch(
                "SELECT * FROM court_fetch_jobs ORDER BY created_at DESC LIMIT $1",
                limit,
            )
    return [_row_to_court_fetch_job(r) for r in rows]


# ── Drain controls (/operations process-management panel) ──────────────────
async def is_drain_disabled(name: str) -> bool:
    """True if the named drain is switched off (drains check this at startup)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        val = await conn.fetchval(
            "SELECT disabled FROM drain_controls WHERE name = $1", name
        )
    return bool(val)


async def set_drain_disabled(name: str, disabled: bool) -> None:
    """Switch a drain on/off (upsert). name = pm2 process name."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "INSERT INTO drain_controls (name, disabled, updated_at) "
            "VALUES ($1, $2, now()) "
            "ON CONFLICT (name) DO UPDATE SET disabled = $2, updated_at = now()",
            name, disabled,
        )


async def get_drain_controls() -> dict[str, bool]:
    """Map of drain name → disabled flag (only rows that were ever toggled)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch("SELECT name, disabled FROM drain_controls")
    return {r["name"]: bool(r["disabled"]) for r in rows}


async def set_drain_burst(name: str, until) -> None:
    """Set/clear a drain's MANUAL burst window (upsert). ``until=None`` clears it.

    Single source of truth shared by the container-side /operations API and the
    host-side supervisor (G1) — no parallel control path (G2)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        await conn.execute(
            "INSERT INTO drain_controls (name, burst_until, updated_at) "
            "VALUES ($1, $2, now()) "
            "ON CONFLICT (name) DO UPDATE SET burst_until = $2, updated_at = now()",
            name, until,
        )


async def get_drain_burst(name: str):
    """The drain's burst_until (datetime) or None."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        return await conn.fetchval(
            "SELECT burst_until FROM drain_controls WHERE name = $1", name
        )


async def get_drain_bursts() -> dict[str, str]:
    """Map of drain name → burst_until ISO string (only rows with a value set)."""
    pool = await get_pool()
    async with pool.acquire() as conn:
        rows = await conn.fetch(
            "SELECT name, burst_until FROM drain_controls WHERE burst_until IS NOT NULL"
        )
    return {r["name"]: r["burst_until"].isoformat() for r in rows}
