"""Configuration loaded from Infisical or central .env file.

Priority: Infisical → environment variables → .env file
"""

import os
from pathlib import Path

from dotenv import load_dotenv

# Load from central .env or override path
dotenv_path = os.environ.get("DOTENV_PATH", str(Path.home() / ".env"))
load_dotenv(dotenv_path)

# Try loading from Infisical if configured
INFISICAL_TOKEN = os.environ.get("INFISICAL_TOKEN", "")
if INFISICAL_TOKEN:
    try:
        from infisical_sdk import InfisicalSDKClient
        _client = InfisicalSDKClient(token=INFISICAL_TOKEN)
        _secrets = _client.get_all_secrets(
            environment=os.environ.get("INFISICAL_ENV", "production"),
            project_id=os.environ.get("INFISICAL_PROJECT_ID", ""),
        )
        for s in _secrets:
            os.environ.setdefault(s.secret_key, s.secret_value)
    except ImportError:
        pass  # Infisical SDK not installed — use .env
    except Exception:
        pass  # Infisical unreachable — fall back to .env

# PostgreSQL
POSTGRES_URL = os.environ.get(
    "POSTGRES_URL",
    f"postgres://{os.environ.get('POSTGRES_USER', 'legal_ai')}:"
    f"{os.environ.get('POSTGRES_PASSWORD', '')}@"
    f"{os.environ.get('POSTGRES_HOST', '127.0.0.1')}:"
    f"{os.environ.get('POSTGRES_PORT', '5433')}/"
    f"{os.environ.get('POSTGRES_DB', 'legal_ai')}",
)

# Redis
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6380/0")

# Claude CLI — model + effort for halacha extraction.
# All LLM calls go through the local `claude -p` CLI (claude_session.py).
# By default the CLI uses the developer's session default model with no
# explicit effort. For halacha extraction we pin Opus 4.8 @ xhigh: the
# 2026-05-31 A/B (scripts/ab_halacha_opus48.py) showed it cuts over-extraction
# (~124→51 on שטיין) at 100% quote-verification with honest confidence
# calibration. Env-overridable so the model/effort can be tuned without a
# code change (set to "" to fall back to the CLI default). Other extractors
# (claims, metadata, block-writing, QA) keep the CLI default unless similarly
# pinned.
HALACHA_EXTRACT_MODEL = os.environ.get("HALACHA_EXTRACT_MODEL", "claude-opus-4-8")
HALACHA_EXTRACT_EFFORT = os.environ.get("HALACHA_EXTRACT_EFFORT", "xhigh")
# Digest (X12) metadata extraction is a simpler, high-volume task (concept tag,
# headline, underlying citation, tags from a one-page summary) — Sonnet is the
# speed/cost sweet-spot here, unlike halacha extraction which pins Opus. Tune via env.
DIGEST_EXTRACT_MODEL = os.environ.get("DIGEST_EXTRACT_MODEL", "claude-sonnet-4-6")
# Effort for BULK queue-drain extraction (process_pending over many precedents).
# xhigh is the quality sweet-spot for a single precedent but very slow at scale
# (a 64-chunk case ≈ 20 min). Bulk drains use a lighter effort to cut wall-clock;
# interactive single re-extraction keeps HALACHA_EXTRACT_EFFORT (xhigh). Tune via
# env (set to 'xhigh' to make bulk match single, or 'medium' for max speed).
HALACHA_BULK_EXTRACT_EFFORT = os.environ.get("HALACHA_BULK_EXTRACT_EFFORT", "high")
# Concurrent chunks WITHIN a single extraction. Each `claude -p` @ xhigh holds
# ~300MB RSS + heavy CPU; cross-process overlap (agent retries) on top of this
# froze the box on 2026-05-31 (hard reboot). A global advisory lock now caps
# the system to ONE extraction at a time; this caps the chunks within it.
HALACHA_CHUNK_CONCURRENCY = int(os.environ.get("HALACHA_CHUNK_CONCURRENCY", "3"))
HALACHA_CORROBORATION_MATCH_FLOOR = float(os.environ.get("HALACHA_CORROBORATION_MATCH_FLOOR", "0.50"))
HALACHA_CORROBORATION_MIN_CITES = int(os.environ.get("HALACHA_CORROBORATION_MIN_CITES", "2"))
# X11 Phase 2: gate corroboration → approval. Default ON (Dafna validated the
# Phase 1 signal, 2026-06-01). Set to "false" to disable the auto-approve/demote
# wiring while keeping the Phase 1 signal intact.
HALACHA_CORROBORATION_AUTO_APPROVE = os.environ.get(
    "HALACHA_CORROBORATION_AUTO_APPROVE", "true"
).strip().lower() in ("1", "true", "yes", "on")

# Voyage AI
VOYAGE_API_KEY = os.environ.get("VOYAGE_API_KEY", "")
VOYAGE_MODEL = os.environ.get("VOYAGE_MODEL", "voyage-law-2")
VOYAGE_DIMENSIONS = 1024

# Rerank — cross-encoder second-stage. Off by default; flip with env to
# enable across all semantic search tools (search_decisions,
# search_case_documents, find_similar_cases, search_precedent_library).
VOYAGE_RERANK_MODEL = os.environ.get("VOYAGE_RERANK_MODEL", "rerank-2")
VOYAGE_RERANK_ENABLED = (
    os.environ.get("VOYAGE_RERANK_ENABLED", "false").lower() == "true"
)
# How many candidates to fetch from bi-encoder before reranking.
# 50 was the depth used in the POC; balances recall vs rerank cost.
VOYAGE_RERANK_FETCH_K = int(os.environ.get("VOYAGE_RERANK_FETCH_K", "50"))

# Multimodal — page-image embeddings via voyage-multimodal-3. Off by
# default; flip with env to enable per-page image embedding during
# ingestion + hybrid (text+image) ranking at search time. POC #3
# validated on a 89-page appraisal PDF (38s, 312K tokens, recovered
# table structure + image-only scanned pages that text-OCR misses).
MULTIMODAL_ENABLED = (
    os.environ.get("MULTIMODAL_ENABLED", "false").lower() == "true"
)
MULTIMODAL_MODEL = os.environ.get("MULTIMODAL_MODEL", "voyage-multimodal-3")
# Render DPI for the image fed to the embedder. POC used 144 — sweet
# spot between embedding quality and tokens/page (144 ≈ 3.5K tok/page).
MULTIMODAL_DPI = int(os.environ.get("MULTIMODAL_DPI", "144"))
# Separate, lower DPI for the JPEG thumbnail saved to disk for UI
# preview. ~96dpi → ~20KB/page; ingestion-time, no re-render at view.
MULTIMODAL_THUMB_DPI = int(os.environ.get("MULTIMODAL_THUMB_DPI", "96"))
# Hybrid merge: Reciprocal Rank Fusion (RRF) bias for the *text* side.
# voyage-3 cosine scores (~0.4-0.5) and voyage-multimodal-3 scores
# (~0.20-0.25) live on different scales; a direct weighted sum lets
# text always dominate. RRF is rank-based and robust to that. The
# weight here biases the contribution of each side: 0.5 = balanced
# (vanilla RRF), >0.5 favours text, <0.5 favours image. Tunable per
# env without redeploy.
MULTIMODAL_TEXT_WEIGHT = float(
    os.environ.get("MULTIMODAL_TEXT_WEIGHT", "0.5")
)
# RRF damping constant. Standard literature value is 60: lower values
# concentrate weight at top ranks; higher values flatten the curve.
MULTIMODAL_RRF_K = int(os.environ.get("MULTIMODAL_RRF_K", "60"))

# BM25/lexical hybrid — fuse ``ts_rank_cd`` over ``content_tsv``/
# ``rule_tsv`` (DB schema V12) with the semantic cosine layer via RRF.
# Recovers recall on exact-string queries that voyage embeddings blur
# (e.g. case-number citations like "1461/20", "317/10"; rare planning
# vocabulary). Hebrew uses the ``simple`` text-search config — no
# stemmer needed, and numeric/punctuation tokens stay intact. When
# disabled, hybrid search falls back to semantic-only (the previous
# behaviour). On by default — the lexical leg is cheap (GIN index) and
# only ever *adds* candidates to RRF, it can't down-rank a strong
# semantic hit.
BM25_HYBRID_ENABLED = (
    os.environ.get("BM25_HYBRID_ENABLED", "true").lower() == "true"
)

# Halacha extraction — auto-approve threshold. Halachot with extractor
# confidence >= this value AND no quality_flags are inserted
# review_status='approved' (so they appear immediately in
# search_precedent_library). Set > 1.0 to disable auto-approval.
#
# CALIBRATION (#81.8, 2026-06-11) against the 100-item human-labeled gold-set
# (db.goldset_calibrate, ground_truth='chair'; 93 keep / 7 drop):
#   conf>=0.80 -> precision 0.98, recall 0.53   <- current (errs safe)
#   conf>=0.75 -> precision 0.96, recall 0.81
#   conf>=0.70 -> precision 0.94, recall 0.94
# 0.80 clears the >=0.90 precision target with margin, so we KEEP it — it errs
# toward the chair (low recall = more items reviewed, never the reverse).
# Two findings shape the policy:
#  (a) self-confidence alone is well-calibrated for PRECISION; the rule-based
#      validators do NOT discriminate keep/drop on the gold-set (P~0.1), so a
#      "confidence x validators" combined score would only hurt — not adopted.
#  (b) the real COVERAGE lever is the tri-model panel (halacha_panel_approve):
#      unanimous-3/3 -> precision 0.988 at 95% coverage, dominating any single
#      confidence threshold. Lowering this gate to ~0.75 is a governance
#      tradeoff (more unreviewed auto-approvals, INV-G10) on thin evidence
#      (7 negatives) -> deferred to chair/panel (TaskMaster #121), not changed here.
HALACHA_AUTO_APPROVE_THRESHOLD = float(
    os.environ.get("HALACHA_AUTO_APPROVE_THRESHOLD", "0.80")
)

# Corpus redesign (#153, chaim 2026-06-20): ELIMINATE the halacha review queue.
# When on (default), extraction never produces 'pending_review' — every extracted
# principle lands as 'approved' = available BACKGROUND (no human review, ever).
# Trust/ranking comes from chair citation (halachot.verified/cite_count), not from
# an approval gate. The nli-audit found the quality flags are 97% false-positive,
# so gating on them only created a phantom backlog (2,402 items). Set false to
# restore the legacy confidence+flags auto-approve gate.
HALACHA_NO_REVIEW_QUEUE = os.environ.get("HALACHA_NO_REVIEW_QUEUE", "true").lower() == "true"

# Corpus redesign (#153): retrieval ranks VERIFIED (chair-cited) principles above
# the unranked BACKGROUND. Added to the halacha similarity score (cosine 0-1): a flat
# boost if the source precedent was chair-cited, plus a small per-citation increment
# (capped). 0 disables (pure similarity). Tunable; calibrate against the canon.
HALACHA_VERIFIED_BOOST = float(os.environ.get("HALACHA_VERIFIED_BOOST", "0.12"))
HALACHA_CITE_BOOST_PER = float(os.environ.get("HALACHA_CITE_BOOST_PER", "0.01"))
HALACHA_CITE_BOOST_CAP = int(os.environ.get("HALACHA_CITE_BOOST_CAP", "10"))

# ── Tri-model panel extraction regime (legal-principles-redesign, #152) ──────
# chaim 2026-06-19: replace single-model auto-approve with a 3-model panel that
# deep-analyzes each decision. 3 models (Claude local + DeepSeek + Gemini) each
# PROPOSE candidate principles with a 0-1 score; candidates are matched across
# models (cosine ≥ MATCH_COSINE) → votes (# distinct models) + score (mean of the
# voters' scores). Approval rule (chaim): 3 votes → approve (even score<floor) ·
# ≥2 votes AND score≥SCORE_FLOOR → approve · 2 votes AND score<floor → chair
# (pending_review, G10) · 1 vote → drop. Cap MAX_NEW genuinely-new principles per
# decision (by score); recognized-existing (V41 cosine link) don't count against
# the cap. Applies to extraction (going forward) AND the retroactive cull (#152).
HALACHA_PANEL_SCORE_FLOOR = float(os.environ.get("HALACHA_PANEL_SCORE_FLOOR", "0.85"))
HALACHA_PANEL_MAX_NEW = int(os.environ.get("HALACHA_PANEL_MAX_NEW", "5"))
# 0.80: legal-principle paraphrases across models land ~0.78-0.82 on voyage-law-2
# (the canonical-synthesis dry-run showed faithful rewrites at 0.78-0.80); too high
# a floor misses genuine cross-model agreement → undercounts votes → over-culls.
# Calibrate against the gold-set in Phase C before the production cull.
HALACHA_PANEL_MATCH_COSINE = float(os.environ.get("HALACHA_PANEL_MATCH_COSINE", "0.80"))
# DEFAULT OFF (#153, chaim 2026-06-20). The panel regime caps extraction to MAX_NEW
# and filters by novelty — empirically PROVEN destructive (8508/1049/1200 each lost
# 22-30 genuine principles incl. the core Lustrenik rule). The corpus redesign keeps
# ALL extracted principles as an unranked BACKGROUND layer (trust comes from chair
# citation, not extraction); so extraction reverts to the legacy rich per-chunk path.
# The panel code is retained (dormant) for optional dedup, never for capping.
HALACHA_PANEL_REGIME_ENABLED = os.environ.get("HALACHA_PANEL_REGIME_ENABLED", "false").lower() == "true"

# Importance layer (#153) — principle-level gold matching. OUR_CHAIR's citations
# (tier-1 gold, protective) vs other chairs' (tier-2 weight). Match threshold: a
# chair-citation's match_context (or a digest's headline_holding) is matched to the
# cited precedent's principles by cosine; ≥ this → the principle is flagged. Tuned
# toward RECALL (a false-negative buries a principle the chair relied on; a
# false-positive merely over-protects). 0.72 = paraphrase floor on voyage-law-2.
OUR_CHAIR_NAME = os.environ.get("OUR_CHAIR_NAME", "דפנה תמיר")
HALACHA_GOLD_MATCH_THRESHOLD = float(os.environ.get("HALACHA_GOLD_MATCH_THRESHOLD", "0.72"))

# Halacha dedup-on-insert — within-precedent semantic cosine ceiling. Before
# storing a halacha, store_halachot_for_chunk skips it if its rule-embedding has
# cosine >= this value against an already-stored halacha of the SAME precedent
# (exact normalized supporting_quote is always skipped regardless). 0.93 is the
# conservative auto-skip floor: the 2026-06-03 cleanup showed the 0.90-0.95 band
# is "almost entirely" same-rule-reworded, but auto-skip is unreviewed so we sit
# just above the manual-cleanup 0.90 to avoid dropping a genuinely distinct
# principle. Set > 1.0 to disable semantic dedup (exact-quote dedup still runs).
HALACHA_DEDUP_COSINE = float(os.environ.get("HALACHA_DEDUP_COSINE", "0.93"))

# Halacha dedup TAIL band (#82.3) — the [BAND_COSINE, DEDUP_COSINE) range is too
# low to auto-skip but suspicious. A halacha whose nearest same-precedent
# neighbor sits in this band AND has high LEXICAL overlap (Jaccard/Levenshtein
# on rule_statement) is flagged 'near_duplicate' (blocks auto-approve → review),
# not skipped — catching paraphrases the cosine threshold misses without
# dropping a possibly-distinct principle unreviewed. 0.83 from the same cleanup.
HALACHA_DEDUP_BAND_COSINE = float(os.environ.get("HALACHA_DEDUP_BAND_COSINE", "0.83"))

# Halacha review-queue clustering (#84.2) — when the review queue is requested
# with cluster=true, halachot of the SAME precedent whose rule-embeddings are
# within this cosine are grouped into ONE review card (canonical + variants), so
# the chair judges near-identical principles once instead of repeatedly. Display
# only — never merges/deletes. 0.90 = "same principle, reworded".
HALACHA_CLUSTER_COSINE = float(os.environ.get("HALACHA_CLUSTER_COSINE", "0.90"))

# Halacha NLI entailment validator (#81.3) — after extraction, a claude_session
# judge checks each halacha's rule_statement is entailed by its supporting_quote.
# Non-entailed (neutral/contradiction) → quality flag 'nli_unsupported' that
# blocks auto-approve. Runs through the local CLI (zero cost); fails OPEN if the
# CLI is unavailable (e.g. container). 'low' effort — entailment is a simple call.
HALACHA_NLI_ENABLED = os.environ.get("HALACHA_NLI_ENABLED", "true").lower() == "true"
HALACHA_NLI_MODEL = os.environ.get("HALACHA_NLI_MODEL", HALACHA_EXTRACT_MODEL)
HALACHA_NLI_EFFORT = os.environ.get("HALACHA_NLI_EFFORT", "low")

# Halacha over-extraction consolidation (#81.5) — after a precedent finishes
# extracting, a claude_session pass folds facets of the SAME legal question
# (below the #82 dedup cosine) into one canonical; the rest are marked rejected
# (reversible). Cross-chunk safety net for over-splitting. Runs through the local
# CLI (zero cost); fails OPEN. 'high' effort — folding needs careful judgment.
HALACHA_CONSOLIDATE_ENABLED = os.environ.get("HALACHA_CONSOLIDATE_ENABLED", "true").lower() == "true"
HALACHA_CONSOLIDATE_MODEL = os.environ.get("HALACHA_CONSOLIDATE_MODEL", HALACHA_EXTRACT_MODEL)
HALACHA_CONSOLIDATE_EFFORT = os.environ.get("HALACHA_CONSOLIDATE_EFFORT", "high")

# V41 canonical lookup-before-insert: cosine gate for reusing an existing canonical
# instead of creating a new one. 0.85 is tuned to the embedding space (1024-dim voyage).
HALACHA_CANONICAL_LOOKUP_ENABLED = os.environ.get("HALACHA_CANONICAL_LOOKUP_ENABLED", "true").lower() == "true"
HALACHA_CANONICAL_THRESHOLD = float(os.environ.get("HALACHA_CANONICAL_THRESHOLD", "0.85"))

# V41 canonical synthesis (Phase 4) — a claude_session pass that rewrites each
# canonical's statement (carried over verbatim from the representative halacha at
# backfill) into ONE clean, case-independent legal principle, grounded in the
# instances' supporting quotes (INV-AH), then flips review_status
# pending_synthesis → pending_review for the chair gate (G10). Opus by default —
# substance-bearing rewrite, chair-facing. Runs through the local CLI (zero $-cost,
# but consumes subscription usage windows → throttled via usage_limits).
# Drift guard: the synthesized statement is re-embedded and compared (cosine) to
# the source; below the floor the synthesis is REJECTED (kept as-is, flagged) so a
# hallucinated/topic-drifted rewrite never silently overwrites a sound principle.
HALACHA_CANONICAL_SYNTH_MODEL = os.environ.get("HALACHA_CANONICAL_SYNTH_MODEL", HALACHA_EXTRACT_MODEL)
HALACHA_CANONICAL_SYNTH_EFFORT = os.environ.get("HALACHA_CANONICAL_SYNTH_EFFORT", "high")
HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR = float(os.environ.get("HALACHA_CANONICAL_SYNTH_DRIFT_FLOOR", "0.80"))

# Lesson synthesis (#158 / INV-LRN8) — mirrors the canonical-halacha synthesis above
# for decision_lessons: cluster overlapping style lessons (cosine ≥ CLUSTER_THRESHOLD,
# within a practice_area+category shard) and merge each cluster into one richer
# "super-lesson" via a local claude_session pass, grounded in the source lessons
# (INV-AH) with a re-embedding DRIFT_FLOOR guard. Opus by default — chair-facing
# quality. The synthesised set is smaller, so the writer's limit=15 stops truncating
# (the real fix for the silent cap, #157).
LESSON_SYNTH_MODEL = os.environ.get("LESSON_SYNTH_MODEL", HALACHA_EXTRACT_MODEL)
LESSON_SYNTH_EFFORT = os.environ.get("LESSON_SYNTH_EFFORT", "high")
LESSON_SYNTH_DRIFT_FLOOR = float(os.environ.get("LESSON_SYNTH_DRIFT_FLOOR", "0.80"))
# Cosine floor for two lessons to land in the same cluster (greedy, within shard).
# Tuned empirically (#158 dry-run, 81 lessons): the lessons are diverse (median
# pairwise cosine ~0.48, max ~0.72), so 0.82 merged nothing. 0.68 captures the real
# near-duplicates (5 faithful clusters across rishuy+betterment, all clearing the
# 0.80 drift gate) without merging weakly-related lessons; the drift gate is the
# quality backstop below this.
LESSON_SYNTH_CLUSTER_THRESHOLD = float(os.environ.get("LESSON_SYNTH_CLUSTER_THRESHOLD", "0.68"))

# How many CHAIR-APPROVED decision_lessons flow to the writer per practice_area
# (get_recent_decision_lessons). Raised 15 → 60 (#158/#157): the lessons are now both
# deduplicated (synthesis merges true near-duplicates) AND chair-approved, so the old
# cap silently truncated distinct, vetted style guidance. 60 covers every current shard
# with headroom; synthesis keeps the set non-redundant as it grows. env-tunable.
WRITER_LESSONS_LIMIT = int(os.environ.get("WRITER_LESSONS_LIMIT", "60"))

# Mistral OCR (fallback for scanned PDFs — replaces Google Cloud Vision)
MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY", "")

# Data directory
DATA_DIR = Path(os.environ.get("DATA_DIR", str(Path.home() / "legal-ai" / "data")))
TRAINING_DIR = DATA_DIR / "training"
EXPORTS_DIR = DATA_DIR / "exports"  # legacy exports only

# Cases directory — flat structure: data/cases/{case_number}/
CASES_DIR = DATA_DIR / "cases"

# ── Object storage (X14 / MinIO) ───────────────────────────────────
# Single storage layer (services/storage.py) replaces the scattered file
# I/O across ~8 services (INV-STG1 / G2). Backend selector:
#   "filesystem" (default) — disk under DATA_DIR; current behaviour, no change.
#   "dual"                 — write disk + S3, read S3→disk fallback (migration).
#   "s3"                   — MinIO only.
# See docs/spec/X14-storage-minio.md.
STORAGE_BACKEND = os.environ.get("STORAGE_BACKEND", "filesystem").strip().lower()
# Endpoint reached server-side (internal Docker network: http://minio:9000).
MINIO_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio:9000")
# Public endpoint used when MINTING presigned URLs for the browser (INV-STG6) —
# the browser cannot resolve the internal hostname. Falls back to the internal
# endpoint when unset (e.g. local dev).
MINIO_PUBLIC_ENDPOINT = os.environ.get("MINIO_PUBLIC_ENDPOINT", MINIO_ENDPOINT)
MINIO_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "")
MINIO_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "")
MINIO_REGION = os.environ.get("MINIO_REGION", "us-east-1")
# Logical bucket → name. Governance boundaries (INV-STG3): documents
# (versioned), immutable (versioned + Object-Lock COMPLIANCE for final
# decisions, INV-STG4), derived (thumbnails/extracted text — regenerable).
MINIO_BUCKET_DOCUMENTS = os.environ.get("MINIO_BUCKET_DOCUMENTS", "legal-documents")
MINIO_BUCKET_IMMUTABLE = os.environ.get("MINIO_BUCKET_IMMUTABLE", "legal-immutable")
MINIO_BUCKET_DERIVED = os.environ.get("MINIO_BUCKET_DERIVED", "legal-derived")
# Default presigned-URL TTL (seconds). SigV4 hard max is 7 days; keep short.
MINIO_PRESIGN_TTL = int(os.environ.get("MINIO_PRESIGN_TTL", "900"))


def find_case_dir(case_number: str) -> Path:
    """Return the case directory for a given case number."""
    return CASES_DIR / case_number

# Chunking parameters
CHUNK_SIZE_TOKENS = 600
CHUNK_OVERLAP_TOKENS = 100

# Parent-doc retrieval (TaskMaster #48) — hierarchical chunking + lookup.
# When enabled:
#   - The ingest pipeline emits two tiers of precedent_chunks: small
#     "child" chunks (~300 tokens) for high-recall semantic/lexical
#     matching, and larger "parent" chunks (~1500 tokens) that contain
#     ~5 children each. Children are embedded and indexed; parents
#     carry the broader text the LLM gets back.
#   - Search runs against children, then swaps each hit for its parent
#     row before returning — so the writer sees a coherent passage
#     instead of a 300-token sliver.
#
# Off by default: the schema (V17) is safe to apply even when the flag
# is false (the chunker still emits single-tier chunks and search just
# returns them unchanged). Flip to true ONLY after the corpus has been
# re-ingested with the hierarchical chunker — see precedent_library
# ingest pipeline + the backfill plan in TaskMaster #48.
PARENT_DOC_RETRIEVAL_ENABLED = (
    os.environ.get("PARENT_DOC_RETRIEVAL_ENABLED", "false").lower() == "true"
)
# Child chunks are what get embedded + matched. Smaller = higher recall,
# more rows. 300 tokens (~600 chars Hebrew) is the empirical sweet spot
# referenced in the original parent-doc literature (Anthropic, LlamaIndex).
PARENT_DOC_CHILD_SIZE_TOKENS = int(
    os.environ.get("PARENT_DOC_CHILD_SIZE_TOKENS", "300")
)
# Parent chunks are what get returned to the LLM. Large enough to hold
# a full rule statement plus the surrounding paragraph and any cited
# authority. 1500 tokens = ~5 children at 300 each.
PARENT_DOC_PARENT_SIZE_TOKENS = int(
    os.environ.get("PARENT_DOC_PARENT_SIZE_TOKENS", "1500")
)
# Child overlap — keeps neighbouring children sharing ~50 tokens so a
# sentence on a chunk boundary still matches the natural phrasing.
PARENT_DOC_CHILD_OVERLAP_TOKENS = int(
    os.environ.get("PARENT_DOC_CHILD_OVERLAP_TOKENS", "50")
)

# External service allowlist — case materials may ONLY be sent to these domains
ALLOWED_EXTERNAL_SERVICES = {
    "api.voyageai.com",         # Voyage AI (embeddings)
    "api.mistral.ai",           # Mistral OCR (scanned PDFs)
}

# Audit
AUDIT_ENABLED = os.environ.get("AUDIT_ENABLED", "true").lower() == "true"


# ── Utility ───────────────────────────────────────────────────────

def parse_llm_json(raw: str):
    """Parse JSON from LLM response, handling markdown wrapping and truncation.

    Handles:
    1. Markdown ```json ... ``` code blocks
    2. Extra text before/after JSON
    3. Truncated JSON (missing closing brackets) — attempts recovery
    """
    import json
    import re
    raw = raw.strip()
    # Strip markdown code blocks
    raw = re.sub(r"^```(?:json)?\s*\n?", "", raw)
    raw = re.sub(r"\n?\s*```\s*$", "", raw)
    # Try direct parse first
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        pass
    # Try to find JSON object or array
    for pattern in [r"\{.*\}", r"\[.*\]"]:
        match = re.search(pattern, raw, re.DOTALL)
        if match:
            try:
                return json.loads(match.group())
            except json.JSONDecodeError:
                continue
    # Attempt truncated JSON recovery:
    # Find the start of JSON, then try closing open brackets
    for opener, closer in [("[", "]"), ("{", "}")]:
        start = raw.find(opener)
        if start < 0:
            continue
        fragment = raw[start:]
        # Try progressively removing trailing partial content and closing
        # Look for the last complete item (ending with }, or ])
        for end_pattern in [r'.*\}(?=\s*,?\s*$)', r'.*\](?=\s*,?\s*$)', r'.*"(?=\s*$)']:
            pass  # fallback below
        # Simple approach: find last complete JSON item boundary
        # For arrays: find last "}" and close the array
        if opener == "[":
            last_brace = fragment.rfind("}")
            if last_brace > 0:
                truncated = fragment[:last_brace + 1] + "]"
                try:
                    return json.loads(truncated)
                except json.JSONDecodeError:
                    pass
        # For objects: find last complete key-value
        if opener == "{":
            last_brace = fragment.rfind("}")
            if last_brace > 0:
                # Check if this closes a nested object — try adding outer close
                truncated = fragment[:last_brace + 1]
                # Count unclosed braces
                open_count = truncated.count("{") - truncated.count("}")
                truncated += "}" * open_count
                try:
                    return json.loads(truncated)
                except json.JSONDecodeError:
                    pass
    return None


# ── Committee chair — single source of truth (INV-G2) ─────────────────
# internal_committee rows REQUIRE a non-empty chair_name (DB constraint
# case_law_internal_chair_check). Our committee (CMP 1xxx, CMPA 8/9xxx) is
# chaired by Dafna Tamir; map by case-number prefix so adding a future chair
# stays a one-line local change. This resolver is the ONE place both the
# FastAPI final-upload path (web/app.py) and the MCP learning path
# (tools/workflow.py + services/db.create_case) derive the chair from — so
# the two cannot drift into parallel logic. Override via env for another
# committee.
COMMITTEE_CHAIR_DEFAULT = os.environ.get("DEFAULT_CHAIR_NAME", "דפנה תמיר")
COMMITTEE_CHAIR_BY_PREFIX = {
    "1": COMMITTEE_CHAIR_DEFAULT,
    "8": COMMITTEE_CHAIR_DEFAULT,
    "9": COMMITTEE_CHAIR_DEFAULT,
}


def committee_chair_for_case(case: dict | None, case_number: str) -> str:
    """Resolve the chair for one of OUR decisions deterministically (no LLM):
    the case's own chair_name, else the committee default by case-number prefix.

    Never returns empty for a valid case number — this is how chair_name is
    normalised at the source (INV-G1) so internal_committee corpus copies of
    finals never silently fail the DB chair constraint.
    """
    existing = ((case or {}).get("chair_name") or "").strip()
    if existing:
        return existing
    return COMMITTEE_CHAIR_BY_PREFIX.get((case_number or "")[:1], COMMITTEE_CHAIR_DEFAULT)
