"""v0.47.0 Part A — Synthetic Data Forge.

Multi-stage synthetic data pipeline: docs/traces → judge generation →
active selection (uncertainty-based pruning) → JSONL with full
provenance graph (which doc, which judge call, which filter score).

Differentiates from existing ``soup data generate`` + ``data augment``
(single-shot) by composing chunking, judge prompts, active pruning and
provenance into a single CLI surface.

Design notes:
- Pure-function math kernel (``chunk_document`` / ``score_uncertainty``)
  so the same routines feed the CLI, future ``soup eval`` integration,
  and (eventually) live trainer callbacks.
- The ``judge`` argument is a callable so callers can plug the v0.20.0
  Ollama / Anthropic / vLLM providers, or a stub in tests. No network or
  ML code lives in this module.
- Atomic write via staged-tempfile + ``os.replace`` (matches v0.43.0
  Part D ``copy_bundle_to`` policy).
- ``os.lstat + S_ISLNK`` rejection on write targets — defends against
  pre-placed symlinks pointing at ``/etc/cron.d/x`` etc. Matches
  v0.33.0 #22 / v0.43.0 Part C / v0.44.0 Part B / v0.45.0 Part E /
  v0.46.0 TOCTOU policy.
"""

from __future__ import annotations

import json
import logging
import math
import os
import re
import stat as _stat
import tempfile
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple

from soup_cli.utils.paths import is_under_cwd

_LOG = logging.getLogger("soup.data_forge")

VALID_TASKS: Tuple[str, ...] = ("sft", "preference", "tool")
_VALID_TASKS_SET = frozenset(VALID_TASKS)

# Allowlist of document extensions. Mirrors v0.42.0 `data ingest` design
# intent — text-shaped corpora only. PDF/DOCX support intentionally lives
# in `soup data ingest`, which feeds JSONL into this pipeline.
_DOC_EXTENSIONS = frozenset({".txt", ".md", ".jsonl", ".json"})

# DoS caps. These match the spirit of v0.42.0 / v0.45.0 / v0.46.0 caps.
_MAX_DOCS = 10_000
_MAX_DOC_CHARS = 4 * 1024 * 1024  # 4 MiB per document
_MAX_TARGET_ROWS = 1_000_000
_MAX_TEACHER_LEN = 256
_MAX_PATH_LEN = 4096
_MAX_CHUNK_CHARS = 64_000


# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class ForgePlan:
    """Declarative plan for a forge run; nothing in here is executed."""

    task: str
    num_docs: int
    target_rows: int
    teacher: str = "local-judge"
    uncertainty_threshold: float = 0.0


@dataclass(frozen=True)
class ProvenanceRecord:
    """One row's audit trail back to source doc + judge call + score."""

    row_id: str
    source_doc: str
    judge_id: str
    filter_score: float
    chunk_id: str


@dataclass(frozen=True)
class ForgeRow:
    """One synthetic dataset row + its provenance."""

    messages: Tuple[Mapping[str, Any], ...]
    provenance: ProvenanceRecord
    task: str
    extra: Mapping[str, Any] = field(default_factory=dict)

    def to_dict(self) -> Dict[str, Any]:
        out: Dict[str, Any] = {
            "messages": [dict(m) for m in self.messages],
            "task": self.task,
            "provenance": {
                "row_id": self.provenance.row_id,
                "source_doc": self.provenance.source_doc,
                "judge_id": self.provenance.judge_id,
                "filter_score": self.provenance.filter_score,
                "chunk_id": self.provenance.chunk_id,
            },
        }
        if self.extra:
            out["extra"] = dict(self.extra)
        return out


# ---------------------------------------------------------------------------
# Validators
# ---------------------------------------------------------------------------


def _validate_str(value: Any, *, name: str, max_len: int) -> str:
    if not isinstance(value, str):
        raise TypeError(f"{name} must be a string")
    if not value:
        raise ValueError(f"{name} must be non-empty")
    if "\x00" in value:
        raise ValueError(f"{name} contains a null byte")
    if len(value) > max_len:
        raise ValueError(f"{name} exceeds {max_len} chars")
    return value


def _validate_int(value: Any, *, name: str, low: int, high: int) -> int:
    # bool is subclass of int — explicit reject (project bool-as-int policy).
    if isinstance(value, bool) or not isinstance(value, int):
        raise TypeError(f"{name} must be an int (not bool)")
    if value < low or value > high:
        raise ValueError(f"{name} must be in [{low}, {high}]")
    return value


def _validate_task(task: Any) -> str:
    if not isinstance(task, str):
        raise TypeError("task must be a string")
    if task not in _VALID_TASKS_SET:
        raise ValueError(
            f"task must be one of {sorted(_VALID_TASKS_SET)}; got {task!r}"
        )
    return task


def _validate_float_unit(value: Any, *, name: str) -> float:
    if isinstance(value, bool):
        raise TypeError(f"{name} must be a float (not bool)")
    if not isinstance(value, (int, float)):
        raise TypeError(f"{name} must be numeric")
    fv = float(value)
    if not math.isfinite(fv):
        raise ValueError(f"{name} must be finite (NaN/Inf rejected)")
    if fv < 0.0 or fv > 1.0:
        raise ValueError(f"{name} must be in [0, 1]")
    return fv


# ---------------------------------------------------------------------------
# Pure-function kernel
# ---------------------------------------------------------------------------


_PARA_SPLIT = re.compile(r"\n\s*\n+")


def chunk_document(text: Any, *, max_chunk_chars: int = 1000) -> List[str]:
    """Split text into chunks no larger than ``max_chunk_chars``.

    Splits on paragraph boundaries first; falls back to hard slice when a
    paragraph alone exceeds the cap. Returns ``[]`` on empty/whitespace input.
    """
    if not isinstance(text, str):
        raise TypeError("text must be a string")
    if "\x00" in text:
        raise ValueError("text contains a null byte")
    if isinstance(max_chunk_chars, bool) or not isinstance(max_chunk_chars, int):
        raise TypeError("max_chunk_chars must be an int (not bool)")
    if max_chunk_chars <= 0:
        raise ValueError("max_chunk_chars must be positive")
    if max_chunk_chars > _MAX_CHUNK_CHARS:
        raise ValueError(f"max_chunk_chars exceeds {_MAX_CHUNK_CHARS}")
    if len(text) > _MAX_DOC_CHARS:
        raise ValueError(f"text exceeds {_MAX_DOC_CHARS} chars")
    stripped = text.strip()
    if not stripped:
        return []
    chunks: List[str] = []
    for para in _PARA_SPLIT.split(stripped):
        para = para.strip()
        if not para:
            continue
        if len(para) <= max_chunk_chars:
            chunks.append(para)
            continue
        # Hard slice paragraphs that exceed the cap on their own.
        for i in range(0, len(para), max_chunk_chars):
            chunks.append(para[i : i + max_chunk_chars])
    return chunks


def _tokens(text: str) -> List[str]:
    return [t for t in re.split(r"\s+", text.strip().lower()) if t]


def score_uncertainty(prediction: Any, reference: Any) -> float:
    """Heuristic uncertainty score (Jaccard distance over token sets).

    Returns 0.0 when prediction == reference, 1.0 when disjoint or either is
    empty. Used for active-learning pruning — high score == high uncertainty,
    so the row is worth keeping.
    """
    if not isinstance(prediction, str) or not isinstance(reference, str):
        raise TypeError("prediction and reference must be strings")
    a = set(_tokens(prediction))
    b = set(_tokens(reference))
    if not a or not b:
        return 1.0
    inter = a & b
    union = a | b
    return 1.0 - (len(inter) / len(union))


# ---------------------------------------------------------------------------
# Plan + discovery
# ---------------------------------------------------------------------------


def discover_documents(docs_dir: Any) -> List[str]:
    """Enumerate document files under ``docs_dir`` (one level deep).

    Skips dotfiles, rejects symlinked directories, restricts to known
    text extensions. Returns absolute realpaths.
    """
    if not isinstance(docs_dir, str):
        raise TypeError("docs_dir must be a string")
    if not docs_dir or "\x00" in docs_dir:
        raise ValueError("docs_dir must be a non-empty NUL-free string")
    if len(docs_dir) > _MAX_PATH_LEN:
        raise ValueError(f"docs_dir exceeds {_MAX_PATH_LEN} chars")
    if not is_under_cwd(docs_dir):
        raise ValueError("docs_dir must stay under cwd")
    if not os.path.isdir(docs_dir):
        raise FileNotFoundError(f"docs_dir not found: {docs_dir!r}")
    try:
        if _stat.S_ISLNK(os.lstat(docs_dir).st_mode):
            raise ValueError("docs_dir must not be a symlink")
    except OSError as exc:  # pragma: no cover — defensive
        raise ValueError(f"cannot stat docs_dir: {exc}") from exc

    real_base = os.path.realpath(docs_dir)
    out: List[str] = []
    try:
        with os.scandir(real_base) as it:
            for entry in it:
                if entry.name.startswith("."):
                    continue
                try:
                    if entry.is_symlink():
                        continue
                except OSError:
                    continue
                if not entry.is_file():
                    continue
                ext = os.path.splitext(entry.name)[1].lower()
                if ext not in _DOC_EXTENSIONS:
                    continue
                out.append(os.path.realpath(entry.path))
                if len(out) >= _MAX_DOCS:
                    break
    except OSError as exc:
        raise ValueError(f"failed to scan docs_dir: {exc}") from exc
    out.sort()
    return out


def build_forge_plan(
    *,
    docs_dir: str,
    task: str,
    target_rows: int,
    teacher: str = "local-judge",
    uncertainty_threshold: float = 0.0,
) -> ForgePlan:
    """Validate inputs and return an immutable plan."""
    task = _validate_task(task)
    target_rows = _validate_int(
        target_rows, name="target_rows", low=1, high=_MAX_TARGET_ROWS
    )
    teacher = _validate_str(teacher, name="teacher", max_len=_MAX_TEACHER_LEN)
    uncertainty_threshold = _validate_float_unit(
        uncertainty_threshold, name="uncertainty_threshold"
    )

    if not isinstance(docs_dir, str) or not docs_dir:
        raise ValueError("docs_dir must be a non-empty string")
    if not is_under_cwd(docs_dir):
        raise ValueError("docs_dir must stay under cwd")
    docs = discover_documents(docs_dir)
    if not docs:
        raise ValueError(f"no documents found under {os.path.basename(docs_dir)!r}")

    return ForgePlan(
        task=task,
        num_docs=len(docs),
        target_rows=target_rows,
        teacher=teacher,
        uncertainty_threshold=uncertainty_threshold,
    )


# ---------------------------------------------------------------------------
# Synthesis (uses a caller-supplied judge function)
# ---------------------------------------------------------------------------


JudgeFn = Callable[[str], Mapping[str, Any]]


def _read_doc_text(path: str) -> str:
    try:
        with open(path, "r", encoding="utf-8") as fh:
            return fh.read(_MAX_DOC_CHARS + 1)
    except OSError:
        return ""


def _make_prompt(chunk: str, task: str) -> str:
    if task == "sft":
        return f"Read the passage and write a Q&A pair.\n\nPassage:\n{chunk}"
    if task == "preference":
        return (
            "Read the passage and write a preferred and a rejected answer.\n\n"
            f"Passage:\n{chunk}"
        )
    if task == "tool":
        return (
            "Read the passage and produce a tool-call hypothesis.\n\n"
            f"Passage:\n{chunk}"
        )
    raise ValueError(f"unknown task: {task!r}")  # pragma: no cover


def synthesise_forge_rows(
    docs: Sequence[str],
    *,
    task: str,
    target_rows: int,
    judge: JudgeFn,
    teacher: str = "local-judge",
    uncertainty_threshold: float = 0.0,
    max_chunk_chars: int = 1000,
) -> List[ForgeRow]:
    """Run the pipeline: chunk → judge → active-prune → ForgeRow rows.

    The ``judge`` callable is invoked once per chunk; failures are swallowed
    at DEBUG (mirrors v0.33.0 #47 CrossDocCollator policy — single bad call
    must not crash the run).
    """
    task = _validate_task(task)
    target_rows = _validate_int(
        target_rows, name="target_rows", low=1, high=_MAX_TARGET_ROWS
    )
    teacher = _validate_str(teacher, name="teacher", max_len=_MAX_TEACHER_LEN)
    uncertainty_threshold = _validate_float_unit(
        uncertainty_threshold, name="uncertainty_threshold"
    )
    if not callable(judge):
        raise TypeError("judge must be a callable")

    rows: List[ForgeRow] = []
    for doc_idx, doc_path in enumerate(docs):
        if len(rows) >= target_rows:
            break
        text = _read_doc_text(doc_path)
        if not text:
            continue
        try:
            chunks = chunk_document(text, max_chunk_chars=max_chunk_chars)
        except (TypeError, ValueError) as exc:
            _LOG.debug("chunk failed for %s: %s", doc_path, exc)
            continue

        for chunk_idx, chunk in enumerate(chunks):
            if len(rows) >= target_rows:
                break
            prompt = _make_prompt(chunk, task)
            try:
                reply = judge(prompt)
            except Exception as exc:  # noqa: BLE001 — judge backends vary
                _LOG.debug("judge raised on %s#%d: %s", doc_path, chunk_idx, exc)
                continue
            if not isinstance(reply, Mapping):
                continue
            reply_text = reply.get("text") or ""
            if not isinstance(reply_text, str):
                continue
            try:
                # Active pruning — Jaccard distance between chunk and reply.
                score = score_uncertainty(reply_text, chunk)
            except (TypeError, ValueError):
                continue
            if score < uncertainty_threshold:
                continue
            row_id = f"r{doc_idx:04d}_{chunk_idx:04d}"
            chunk_id = f"d{doc_idx:04d}_c{chunk_idx:04d}"
            messages: Tuple[Mapping[str, Any], ...] = (
                {"role": "user", "content": prompt},
                {"role": "assistant", "content": reply_text},
            )
            prov = ProvenanceRecord(
                row_id=row_id,
                source_doc=doc_path,
                judge_id=teacher,
                filter_score=score,
                chunk_id=chunk_id,
            )
            rows.append(ForgeRow(messages=messages, provenance=prov, task=task))
    return rows


# ---------------------------------------------------------------------------
# Writers (atomic + symlink-rejecting)
# ---------------------------------------------------------------------------


def _check_write_path(path: Any, *, label: str) -> str:
    if not isinstance(path, str):
        raise TypeError(f"{label} must be a string")
    if not path or "\x00" in path:
        raise ValueError(f"{label} must be a non-empty NUL-free string")
    if len(path) > _MAX_PATH_LEN:
        raise ValueError(f"{label} exceeds {_MAX_PATH_LEN} chars")
    if not is_under_cwd(path):
        raise ValueError(f"{label} must stay under cwd")
    # TOCTOU: a pre-placed symlink at the target would let `os.replace`
    # follow into an attacker-controlled location.
    try:
        if _stat.S_ISLNK(os.lstat(path).st_mode):
            raise ValueError(f"{label} must not be a symlink")
    except FileNotFoundError:
        pass
    return os.path.realpath(path)


def _atomic_write(path: str, payload: bytes) -> None:
    parent = os.path.dirname(path) or "."
    os.makedirs(parent, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(prefix=".forge-", dir=parent)
    try:
        # Symlink check at the staged path too — defence in depth against
        # tempfile dirs an attacker might race.
        if _stat.S_ISLNK(os.lstat(tmp_path).st_mode):
            os.close(fd)
            os.unlink(tmp_path)
            raise ValueError("staged tempfile is a symlink")
        with os.fdopen(fd, "wb") as fh:
            fh.write(payload)
        os.replace(tmp_path, path)
    except Exception:
        # Best-effort cleanup; never mask the original error.
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def write_forge_dataset(rows: Sequence[ForgeRow], path: str) -> str:
    """Write rows as JSONL, atomically. Returns the realpath."""
    target = _check_write_path(path, label="dataset output")
    buf = []
    for row in rows:
        if not isinstance(row, ForgeRow):
            raise TypeError("rows must be ForgeRow instances")
        buf.append(json.dumps(row.to_dict(), ensure_ascii=False))
    payload = ("\n".join(buf) + ("\n" if buf else "")).encode("utf-8")
    _atomic_write(target, payload)
    return target


def write_provenance(rows: Sequence[ForgeRow], path: str) -> str:
    """Write provenance manifest JSON, atomically. Returns the realpath."""
    target = _check_write_path(path, label="provenance manifest")
    records = []
    for row in rows:
        if not isinstance(row, ForgeRow):
            raise TypeError("rows must be ForgeRow instances")
        prov = row.provenance
        records.append(
            {
                "row_id": prov.row_id,
                "source_doc": prov.source_doc,
                "judge_id": prov.judge_id,
                "filter_score": prov.filter_score,
                "chunk_id": prov.chunk_id,
            }
        )
    payload = json.dumps(
        {"version": 1, "row_count": len(records), "records": records},
        ensure_ascii=False,
        indent=2,
    ).encode("utf-8")
    _atomic_write(target, payload)
    return target


# --- v0.53.7 #111: live judge providers -----------------------------------
#
# Build a callable ``judge(prompt: str) -> dict`` from one of the v0.20.0
# providers (Ollama / Anthropic / vLLM). Security carry-overs:
# - Ollama: localhost-only (``validate_ollama_url``).
# - Anthropic: API key from env var only (``ANTHROPIC_API_KEY``).
# - vLLM: scheme allowlist + localhost-only HTTP (``validate_vllm_url``).

JUDGE_PROVIDERS: frozenset[str] = frozenset({"ollama", "anthropic", "vllm"})

# Loopback-only default for live judge backends. Operators wanting a remote
# Ollama / vLLM must explicitly override via ``--judge-base-url``.
_OLLAMA_DEFAULT_URL = "http://localhost:11434"
_VLLM_DEFAULT_URL = "http://localhost:8000"


def make_judge_provider_fn(
    provider: str,
    *,
    model: str = "llama3.1",
    base_url: Optional[str] = None,
    temperature: float = 0.7,
    timeout_seconds: float = 60.0,
) -> "Callable[[str], Mapping[str, Any]]":
    """Build a ``judge(prompt) -> {'text': str}`` callable for ``provider``.

    Args:
        provider: one of ``"ollama"`` / ``"anthropic"`` / ``"vllm"``.
        model: backend model name (e.g. ``llama3.1`` for Ollama,
            ``claude-3-5-sonnet-latest`` for Anthropic).
        base_url: HTTP base URL (Ollama / vLLM only). Defaults to loopback.
        temperature: sampling temperature.
        timeout_seconds: per-call HTTP timeout.

    Returns:
        Callable signature ``(prompt: str) -> Mapping[str, Any]``. The
        returned mapping always carries a ``"text"`` field (possibly empty
        on backend failure — matches the ``synthesise_forge_rows`` judge
        contract that ignores non-Mapping or empty-text replies).

    Raises:
        ValueError: unknown provider, bad URL, or missing Anthropic key.
        ImportError: if ``httpx`` is not installed.
    """
    if not isinstance(provider, str):
        raise TypeError("provider must be a string")
    canonical = provider.strip().lower()
    if canonical not in JUDGE_PROVIDERS:
        raise ValueError(
            f"unknown judge provider: {provider!r}. "
            f"supported: {sorted(JUDGE_PROVIDERS)}"
        )
    if not isinstance(model, str) or not model or "\x00" in model:
        raise ValueError("model must be a non-empty NUL-free string")
    if isinstance(timeout_seconds, bool) or not isinstance(
        timeout_seconds, (int, float)
    ):
        raise TypeError("timeout_seconds must be a number")
    if timeout_seconds <= 0 or timeout_seconds > 600:
        raise ValueError("timeout_seconds must be in (0, 600]")
    # v0.53.7 M-M: explicit bool rejection on ``temperature`` — Python
    # treats ``True`` as ``1`` and would silently round-trip through
    # ``float(temperature)`` further down.
    if isinstance(temperature, bool) or not isinstance(
        temperature, (int, float)
    ):
        raise TypeError("temperature must be a number")
    if temperature < 0 or temperature > 2:
        raise ValueError("temperature must be in [0, 2]")

    try:
        import httpx
    except ImportError as exc:
        raise ImportError(
            "httpx is required for live judge providers. "
            "Run: pip install httpx"
        ) from exc

    if canonical == "ollama":
        from soup_cli.data.providers.ollama import validate_ollama_url

        url = base_url or _OLLAMA_DEFAULT_URL
        validate_ollama_url(url)
        api_url = f"{url}/v1/chat/completions"

        def _ollama_judge(prompt: str) -> Mapping[str, Any]:
            if not isinstance(prompt, str):
                return {"text": ""}
            try:
                resp = httpx.post(
                    api_url,
                    headers={"Content-Type": "application/json"},
                    json={
                        "model": model,
                        "messages": [{"role": "user", "content": prompt}],
                        "temperature": float(temperature),
                        "max_tokens": 1024,
                    },
                    timeout=timeout_seconds,
                )
            except Exception as exc:  # noqa: BLE001 — httpx error variety
                _LOG.debug("ollama judge HTTP error: %s", exc)
                return {"text": ""}
            if resp.status_code != 200:
                _LOG.debug("ollama judge status=%d", resp.status_code)
                return {"text": ""}
            try:
                data = resp.json()
                text = data["choices"][0]["message"]["content"]
            except (KeyError, IndexError, TypeError, ValueError) as exc:
                _LOG.debug("ollama judge parse error: %s", exc)
                return {"text": ""}
            return {"text": text if isinstance(text, str) else ""}

        return _ollama_judge

    if canonical == "anthropic":
        import os as _os

        api_key = _os.environ.get("ANTHROPIC_API_KEY")
        if not api_key:
            raise ValueError(
                "Anthropic judge provider requires ANTHROPIC_API_KEY env var."
            )

        def _anthropic_judge(prompt: str) -> Mapping[str, Any]:
            if not isinstance(prompt, str):
                return {"text": ""}
            try:
                resp = httpx.post(
                    "https://api.anthropic.com/v1/messages",
                    headers={
                        "x-api-key": api_key,
                        "anthropic-version": "2023-06-01",
                        "Content-Type": "application/json",
                    },
                    json={
                        "model": model,
                        "messages": [{"role": "user", "content": prompt}],
                        "temperature": float(temperature),
                        "max_tokens": 1024,
                    },
                    timeout=timeout_seconds,
                )
            except Exception as exc:  # noqa: BLE001
                _LOG.debug("anthropic judge HTTP error: %s", exc)
                return {"text": ""}
            if resp.status_code != 200:
                _LOG.debug("anthropic judge status=%d", resp.status_code)
                return {"text": ""}
            try:
                data = resp.json()
                blocks = data["content"]
                text = "".join(
                    b["text"] for b in blocks if b.get("type") == "text"
                )
            except (KeyError, IndexError, TypeError, ValueError) as exc:
                _LOG.debug("anthropic judge parse error: %s", exc)
                return {"text": ""}
            return {"text": text}

        return _anthropic_judge

    # vLLM
    from soup_cli.data.providers.vllm import validate_vllm_url

    url = base_url or _VLLM_DEFAULT_URL
    validate_vllm_url(url)
    api_url = f"{url}/v1/chat/completions"

    def _vllm_judge(prompt: str) -> Mapping[str, Any]:
        if not isinstance(prompt, str):
            return {"text": ""}
        try:
            resp = httpx.post(
                api_url,
                headers={"Content-Type": "application/json"},
                json={
                    "model": model,
                    "messages": [{"role": "user", "content": prompt}],
                    "temperature": float(temperature),
                    "max_tokens": 1024,
                },
                timeout=timeout_seconds,
            )
        except Exception as exc:  # noqa: BLE001
            _LOG.debug("vllm judge HTTP error: %s", exc)
            return {"text": ""}
        if resp.status_code != 200:
            _LOG.debug("vllm judge status=%d", resp.status_code)
            return {"text": ""}
        try:
            data = resp.json()
            text = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, TypeError, ValueError) as exc:
            _LOG.debug("vllm judge parse error: %s", exc)
            return {"text": ""}
        return {"text": text if isinstance(text, str) else ""}

    return _vllm_judge


__all__ = [
    "VALID_TASKS",
    "ForgePlan",
    "ForgeRow",
    "JUDGE_PROVIDERS",
    "ProvenanceRecord",
    "build_forge_plan",
    "chunk_document",
    "discover_documents",
    "make_judge_provider_fn",
    "score_uncertainty",
    "synthesise_forge_rows",
    "write_forge_dataset",
    "write_provenance",
]
