import os
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = PROJECT_ROOT / "templates"
STORAGE_DIR = PROJECT_ROOT / "storage"
FRONTEND_DIST = PROJECT_ROOT / "frontend" / "dist"
DB_PATH = STORAGE_DIR / "creator.db"
PROJECTS_DIR = PROJECT_ROOT / "projects"
UNI_DIR = PROJECT_ROOT / "uni"


def _load_env(path: Path) -> None:
    """Mini .env loader (no dependency): KEY=VALUE lines. The FILE wins over inherited
    env: a --reload master keeps its startup environment forever, so "existing env wins"
    silently pinned stale values across .env edits (measured: file said 24, workers
    inherited 15 for hours). Trade-off: ad-hoc shell overrides lose against the file."""
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key, value = key.strip(), value.strip().strip('"').strip("'")
        if key:
            os.environ[key] = value


_load_env(PROJECT_ROOT / ".env")

MAX_CONCURRENT_GENERATIONS = 10

# Readability gate: deterministic checker (small German complexity model,
# scale 1–7). Sections that are too hard go into the read-exam revision.
# If transformers/torch or the model are missing → gate silently off.
READABILITY_ACTIVE = True
READABILITY_MODEL = "MiriUll/distilbert-german-text-complexity"
# Anchors on the 1–7 scale (TextComplexityDE): plain language ~1.2; Wikipedia average
# ~3.22; from MOS > 4 a sentence counts as "truly complex" (the paper's simplification cutoff).
READABILITY_MAX = 3.5          # section too hard when the sentence average is above this
READABILITY_HARD = 4.0         # an individual sentence is "hard" from here on
READABILITY_HARD_SHARE = 0.30  # … OR when this share of sentences is hard

# Kanban clustering: semantic embeddings drive the online title clustering and the
# candidate pairs of the pair check. If transformers/torch are missing or the model
# won't load → embedding silently off (all pairs go to the judge, clusters stay singletons).
EMBEDDING_AKTIV = True
EMBEDDING_MODELL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"  # CPU, multilingual, ~470 MB
# Stronger (larger) CPU alternative if needed: "BAAI/bge-m3".
# Consolidation = two-stage: (1) the embedding builds COARSE similarity blocks (high recall),
# (2) an LLM judge groups EACH block into the real blocks (merge paraphrases, split
# over-merges). Pure threshold blocking creates a giant component (everything chained) →
# hence "capped blocking": greedily merge by cosine, but cap the block size. This keeps the
# LLM lists short and stable (evidenced: embedding block + LLM judge ≈ 95% precision).
EMBEDDING_BLOCK_FLOOR = 0.5   # minimum cosine for two candidates to share ONE block
EMBEDDING_BLOCK_CAP = 25      # max. titles per block (keep the LLM list short/stable)
# Subblock dedup: purely deterministic (no LLM). Subblocks are short statements IN THE SAME
# block context — from this cosine on two are the same statement (checked on aak: ≥0.88 are
# without exception true duplicates). Conservative 0.90 so different aspects (∈NP ≠ NP-hard) stay separate.
EMBEDDING_SUB_DUP = 0.90
# Variant folding BEFORE the subblock consensus count: finders rephrase the same concept each
# round, so exact-norm counting starves real concepts (measured Markdown run: 623/965 mentions
# discarded, „Zeichenkodierung" 73/74). 0.90 folds true paraphrases at ~0 false folds (0.85/0.88
# fold distinct aspects like ** vs ***). Antonym pairs measure 0.91–0.95 → negation guard required.
SUB_VARIANT_COS = 0.90
# Seed coverage check is LEXICAL first (token containment) — seeds are short fragment NAMES,
# subs are statements: true covers measure 0.27–0.38 while a wrong hit measured 0.76. The
# embedding stage only backs up the lexical one (catches „Line Breaks (Soft)" 0.888).
SEED_COVER_COS = 0.80
# Sub duplicate CANDIDATE floor for the judge paths (in-block consolidation band hint,
# cross-block stage, QA detector): the bulk of real paraphrase duplicates measures 0.75–0.90
# (Markdown: 50 pairs in the band, 4 above) — below every auto-merge threshold, so an LLM
# judge decides. Candidates only; a merge still needs judge unanimity.
SUB_DUP_KANDIDAT_COS = 0.75
# Cross-block judge pairs per call: ONE call over all pairs scaled its timeout to 54 min
# and a hung call blocked the barrier that long (aak: 196 pairs) — chunks cap it at ~15 min.
CROSS_CHUNK_PAARE = 40

# Umbrella grouping (block granularity level 2, step "Blocks-Gruppierung", AFTER the filter):
# collapse sibling DEFINITIONS that are components of ONE umbrella concept (TM model:
# Konfiguration/Start-/Folge-/Stopkonfiguration/Berechnung/Alphabet/δ; KNF: Literale/Klauseln/
# Variablen) into ONE block whose description ENUMERATES the children — so the later subblock step
# re-derives them from the source under the umbrella scope (demotion is re-derivation, not transfer).
# If the flag is off or no embedding model → step is silently skipped (like Dedup).
BLOCKS_GRUPPIERUNG_AKTIV = True
# Lower floor than consolidation (0.5, paraphrase-tuned) for higher sibling recall; the LLM judge is
# the precision gate. Smaller cap since a lower floor pulls in more nodes → keep the judge lists short.
EMBEDDING_SIBLING_FLOOR = 0.35   # heterogeneous facets of one model co-cluster weakly → low floor (recall)
EMBEDDING_SIBLING_CAP = 18       # a rich model (TM) can have many constituent parts
# Reconcile pass: two independently-judged clusters can emit the SAME parent concept under different
# titles (e.g. two „Turingmaschine"-umbrellas). Merge umbrella pairs whose title+description cosine is
# ≥ this (conservative → only true same-parent duplicates, never two distinct umbrellas).
GROUP_RECONCILE_FLOOR = 0.75
# Over-merge backstop ONLY (no-structure floor). Research (meronymy ≠ similarity): parts of ONE model are
# legitimately DISSIMILAR (TM: Alphabet/Konfiguration/δ ~0.22), while distinct same-type concepts (P/NP/…)
# are SIMILAR (~0.85) — so member-vs-member cosine is the WRONG instrument for over-merge (empirically
# inverted: TM 0.218 < the P/NP bundle 0.227). The real precision floor is the ATOMICITY type-guard
# (_GROUP_STANDALONE: a member that is a named algorithm/problem/theorem/complexity-class dissolves the
# umbrella). This floor is demoted to a near-zero backstop that only rejects a literally structureless
# chain (random-pair baseline), set BELOW the legitimate heterogeneous minimum so it never kills a real model.
GROUP_MIN_COS_FLOOR = 0.15
# Fragment-demote backstop, same logic as GROUP_MIN_COS_FLOOR: fragment↔parent cosine is a BAD
# fragment detector (measured, Markdown run: wrong demotes Blockzitate→Codeblöcke 0.353 and
# Zeichenkodierung→Überschriften 0.640 sit ABOVE any usable floor, while true NP proof-gadget
# demotes αu-Variablen→Cook/Levin 0.172 sit low). So this only vetoes judge/panel demotes with
# NO containment match whose pair is literally structureless (Emoji→Tabelle 0.136).
FRAGMENT_MIN_COS = 0.15

# Caps for concurrent CLI agent processes (env-overridable). Two nested limits, both always active:
# a per-topic cap and a global cap across all topics. Defaults 10/10 = previous behavior (global
# dominates). Locally raise the global cap to actually parallelize across topics (per-topic stays 10).
# Own lane for interactive calls (chat, elements) so they don't hang behind running writers.
MAX_CONCURRENT_AGENTS = int(os.getenv("MAX_CONCURRENT_AGENTS", "16"))                      # global, all topics
# Per-topic higher than the process cap: it is SHARED between process and API tier — at 12 a
# single-topic run (the normal case) would never benefit from the cheap API tier.
MAX_CONCURRENT_AGENTS_PER_TOPIC = int(os.getenv("MAX_CONCURRENT_AGENTS_PER_TOPIC", "24"))  # per topic
# Direct-API text calls (agents._run_text_api): ~0 RAM, only network — own, higher global cap.
MAX_CONCURRENT_API_AGENTS = int(os.getenv("MAX_CONCURRENT_API_AGENTS", "28"))
MAX_CONCURRENT_INTERACTIVE = 8

# RAM guard for opencode spawns: below this free share (MemAvailable/MemTotal in %) new
# processes wait instead of starting (agents._ram_gate). 0 = off.
RAM_MIN_FREE_PCT = int(os.getenv("RAM_MIN_FREE_PCT", "20"))

# Grace window of the consensus races (blocks, guide, OnePager): after the first
# valid result the remaining agents may still become done for this many seconds
# (kill only once the minimum is already in).
CONSENSUS_GRACE = 300

# Cap of the clarification and check loops: maximum rounds until everything must be
# decided. In the last round the mapping agent MUST decide every entry;
# check loops leave any remaining objections standing after that.
CONSENSUS_MAX_ROUNDS = 3

# QA gate: after the inventory phase an automatic QA run scores the blocks; below the
# threshold the flow PAUSES before board 2 burns tokens (frontend offers force-continue).
QA_GATE_NOTE = 9.5   # 0 = gate off; quota-based, so the tolerated finding count scales with topic size
QA_GATE_LLM = True   # include the LLM samples (Echtheit/Dubletten) in the gate run

# Inline evidence for judge agents: corpus excerpts go INTO the prompt instead of letting
# every judge re-search the source folder (measured: ~10 tool turns/judge, 82 % of the
# run's tokens were cache reads from those loops).
EVIDENCE_BUDGET_CHARS = 48_000   # max excerpt characters per judge prompt
EVIDENCE_CTX_LINES = 15          # context lines around a cited source position (facts check)

# ── Pipeline tuning (zentral, tunebar via CREATOR_PARAMS — siehe Override-Hook am Datei-Ende;
# Registry mit Suchraum: backend/train_params.py). QA-/Detektor-Konstanten bleiben bewusst in
# qa.py/guide_qa.py — die Messlatte darf nie Teil des Suchraums sein. ─────────────────────────
SUBBLOCK_MAX = 40          # chunk cap
RESEARCH_BATCH = 1         # eine Seite pro Reader — er kann nichts übersehen (Vollständigkeit)
RESEARCH_READERS = 2       # reader agents per batch/section (consensus ≥2)
RESEARCH_THEMA_AGENTS = 5  # web mode (source "thema")
RESEARCH_SECTION_CHARS = 12000  # uni/projekt section size (lost-in-the-middle guard)
RESEARCH_RUNTIME = 900     # one research agent, one round (tail ingests live)
DEDUP_PAIR_FLOOR = 0.6     # min cosine for a candidate pair
DEDUP_PAIRS_CHUNK = 40     # pairs per judge package
DEDUP_TITLE_AUTO = 0.95    # near-identical TITLE cosine → merge without judge
DEDUP_GLOBAL_FLOOR = 0.65  # global post-naming dedup candidate floor
FILTER_CHUNK = 35          # blocks per judge in the degrade pass
CONSOLIDATION_PANEL = 3    # mapping judges per chunk
# Board 2, verschmolzene Calls (block_calls.py): Panel-Größen der neuen Struktur.
# Konsens braucht ≥2 unabhängige Nennungen bzw. Einstimmigkeit — 2 ist das Minimum,
# 3 kauft Robustheit für +50 % Tokens auf dem jeweiligen Segment.
GEN_PANEL = 2              # unabhängige Generator-Calls pro Block
VERIFY_PANEL = 2           # unabhängige Prüfer-Calls pro Block (+ Ersatz bei 1 Ausfall)
ART_SPLIT_SUBS = 20        # Artefakt-Generator splittet ab so vielen Subs in 2 parallele Calls
FILTER_RECHECK_PANEL = 3   # judges in the survivor-recheck
GATE_FIX_MIN = 3           # fact-gate: unbelegt-claims below min(this, relevante Subs) → log only (falsch fixt immer)
ZIELE_MAX = 12             # Lernziele-Cap pro Block (mehr verwässert Coverage-Prüfung und Writer-Fokus)
# Prüfer-Längenband um guide_qa.block_budget: enger als das QA-Band (0.35–1.5), damit der
# Fix VOR der QA-Grenze greift. Die QA-Messlatte selbst bleibt bewusst in guide_qa.py.
FIX_LAENGE_BAND = (0.5, 1.2)
WRITER_SPLIT_SUBS = 30     # guide writer splits sections above this sub count
KANBAN_BATCH = 5           # cards a worker pulls per micro-batch
MAX_CARD_RETRIES = 3       # failures per card → dead-letter
RETRY_BACKOFF = 30.0       # base seconds; backoff = base · 2^(retries-1)
MAX_RESTARTS = 2           # agent restart cap per race slot
INFRA_MAX_RETRIES = 3      # 429/Timeout/Netz: Retries pro Slot, dann Lauf-Pause (kein fail-open)
INFRA_BACKOFF_BASE = 8.0   # Pause = base · 2^(n-1) → 8/16/32 s
# Stall-Hedge: läuft ein Race-Slot so lange ohne Ergebnis, startet parallel ein Zwilling
# (key -h), der erste valide gewinnt. Gemessen (kanban-smoke): 4 Panel-Stalls à 160–230 s
# verlängerten den kritischen Pfad um ~5 min. UNTERGRENZE: effektiv gilt
# max(HEDGE_NACH_S, halbes Call-Timeout) — pauschale 90 s hedgten jeden gesunden
# Fix-/Gate-Call (die laufen normal 110–135 s). 0 = aus.
HEDGE_NACH_S = 90
JUDGE_CHUNK = 40           # repair: findings per judge call
EVENTS_RETENTION_TAGE = 60 # events älter als das werden beim Start gelöscht (Tabelle wuchs unbegrenzt)
EVIDENCE_PER_BLOCK = 6000  # repair: excerpt chars per fremd candidate
ABSCHLUSS_QA_LLM = 1       # 0 = Abschluss-QA ohne LLM-Judges (Training misst selbst; spart Minuten)

# Timeouts per agent step: (base seconds, seconds per block/section).
# Applies equally to all providers — whoever is too slow gets restarted or overtaken.
TIMEOUTS = {
    "research_mapping": (600, 3),  # n = pre-merged entries
    "selection_mapping":   (600, 2),  # n = remaining entries (block inventory)
    "ergaenzung":    (600, 0),   # subject-field extension for projects (web research)
    "plan":          (300, 5),
    "plan_judge":    (600, 5),    # judge reads up to 5 outlines, n = sections
    # Judge caps tightened 2026-07-04: judge p50 is 6–72 s; a stalled call burns the whole
    # cap and its retry heals in seconds — the old 300 s base tripled the stall cost.
    "subblock_check": (150, 10),  # judge decides contested subblocks in the chunk
    "relevance":          (300, 10),  # subblocks relevant/peripheral per chunk
    # Board 2, verschmolzene Calls: größere Outputs pro Call, dafür wenige Segmente
    "generate":       (450, 0),   # Subs+Facts+Level in einem (Sub-Zahl vorab unbekannt)
    "verify":         (300, 10),  # Audit über alle Subs (n = Subs), key points gekappt
    "fix":            (300, 15),  # Korrekturen + Lücken (n = Aufträge)
    "artefakt":       (450, 15),  # Fragen+Karten+Beispiele (n = Subs)
    "artefakt_check": (200, 8),   # Beispiel-Verifikation + Fragen-Kritik (n = Subs)
    "writer":        (450, 60),   # per section — split keeps sections ≤30 subs
    # guide board (per card = one block)
    "lernziele":     (300, 5),    # backward-design objectives per block
    "pruefer":       (600, 5),    # verschmolzener Qualitäts-Pass (Gate+Coverage+Lese) per block
    # QA/Repair-Judge-Wellen (qa.judge_wave) — außerhalb der Boards, keine n-Skalierung
    "qa_judge":      (600, 0),
}

# Provider stacks: completely independent, any one can be removed at any time.
# Roles: "quick" = bulk work (research, classification),
#        "fast"  = interaction + voting (chat, exam, clarification, elements),
#        "judge" = mapping/judge/check agents — cold (low temperature,
#                  no thinking) for stable verdicts; Claude/local map to "fast",
#        "guide" = large generation (proposals, writer).
# Kein Provider-Default im Code (Betreiber-Vorgabe): die .env entscheidet.
DEFAULT_PROVIDER = os.getenv("DEFAULT_PROVIDER", "")
if not DEFAULT_PROVIDER:
    raise RuntimeError("DEFAULT_PROVIDER fehlt in der .env (z. B. DEFAULT_PROVIDER=minimax)")
PROVIDERS = {
    "claude": {
        "cli": "claude",
        "guide": "claude-opus-4-8[1m]",
        "fast": "claude-sonnet-4-6",
        "judge": "claude-sonnet-4-6",  # the CLI has no temperature setting
        "quick": "claude-sonnet-4-6",
        "env_key": None,  # auth via CLAUDE_CODE_OAUTH_TOKEN or ~/.claude
    },
    # "minimax-kalt/…" is NOT its own stack, just an opencode provider entry
    # (dev-ops/opencode.json) with low temperature; M3 there without thinking.
    "minimax": {
        "cli": "opencode",
        "guide": "minimax/MiniMax-M3",
        "fast": "minimax-kalt/MiniMax-M2.7-highspeed",
        "judge": "minimax/MiniMax-M3",  # native route — the kalt endpoint stalled 20 % of
                                        # judge calls to the timeout cap (516/2590, 2026-07-04)
        "quick": "minimax/MiniMax-M2.7-highspeed",
        "env_key": "MINIMAX_API_KEY",
    },
    "lokal": {
        "cli": "opencode",
        "guide": "ollama/qwen3.6:27b",
        "fast": "ollama/qwen3.5:9b",
        "judge": "ollama/qwen3.5:9b",
        "quick": "ollama/qwen3.5:9b",
        "env_key": None,
        "check_url": "http://localhost:11434/api/tags",  # Ollama reachable?
    },
}

# Role routing: by DEFAULT the run's provider (the UI choice) handles ALL roles —
# the role only picks the model WITHIN that stack (PROVIDERS[stack][role]).
# Opt-in cross-provider mixing via env: ROLE_JUDGE=claude routes every judge call
# to the claude stack regardless of the UI choice ("provider:model" pins a model).
ROLE_ROUTING = {
    "quick": os.getenv("ROLE_QUICK", ""),
    "judge": os.getenv("ROLE_JUDGE", ""),
    "guide": os.getenv("ROLE_GUIDE", ""),
    "fast":  os.getenv("ROLE_FAST", ""),
}


def resolve_role(run_provider: str, role: str) -> tuple[str, str]:
    """→ (provider, model) for one agent call. Pure routing, no availability check —
    the caller (agents.run_agent) falls back to run_provider if the target is unavailable."""
    target = ROLE_ROUTING.get(role, "") or run_provider
    provider, _, model = target.partition(":")
    if provider not in PROVIDERS:
        provider, model = run_provider, ""
    if not model:
        model = PROVIDERS.get(provider, {}).get(role, "")
    return provider, model


# ── Trainings-Override: CREATOR_PARAMS (JSON-Dict im ENV) überschreibt gleichnamige
# Tuning-Konstanten oben — pro Prozess-Start (der Trainer startet je Trial einen Subprozess;
# Module binden die Werte beim Import). TIMEOUTS-Einträge via "TIMEOUT_<step>_base"/"_per".
def _apply_param_overrides() -> None:
    raw = os.getenv("CREATOR_PARAMS")
    if not raw:
        return
    import json as _json
    try:
        overrides = _json.loads(raw)
    except ValueError:
        raise SystemExit(f"CREATOR_PARAMS ist kein gültiges JSON: {raw[:80]}")
    g = globals()
    for key, val in overrides.items():
        if key.startswith("TIMEOUT_"):
            rest = key[len("TIMEOUT_"):]
            step, _, part = rest.rpartition("_")
            if step in TIMEOUTS and part in ("base", "per"):
                base, per = TIMEOUTS[step]
                TIMEOUTS[step] = (val, per) if part == "base" else (base, val)
                continue
            raise SystemExit(f"CREATOR_PARAMS: unbekannter Timeout-Schlüssel {key}")
        if key not in g or not isinstance(g[key], (int, float)) or isinstance(g[key], bool):
            raise SystemExit(f"CREATOR_PARAMS: unbekannter/nicht-numerischer Parameter {key}")
        g[key] = type(g[key])(val)


_apply_param_overrides()
