import hashlib
import json
import re
import subprocess
import sys
import threading
import time
from datetime import datetime, timezone

from jlens.lens import JacobianLens

import config
from core.gpus import gpu_stats

FITS_DIR = config.DATA_DIR / "fits"
WORKER = config.ROOT / "scripts" / "fit_worker.py"

# Fit corpus. Any HuggingFace dataset id works as-is: wikitext is the default
# and keeps a dedicated streamed path, every other id goes through the generic
# loader below. Several ids = an equal-parts mix.
DATASET_WIKITEXT = "Salesforce/wikitext-103-raw-v1"

# Seed shared by the row sampling and the mix shuffle: a continued fit that asks
# for skip+n rows deterministically extends the sequence it drew the first n
# from (sample(skip+n) then drop the head).
_SAMPLE_SEED = 1729


def _slug(dataset):
    """Short, filename-safe tag from a dataset id's last path segment, e.g.
    ``heretic-org/Semantic-Harmless`` -> ``semantic-harmless``."""
    tail = dataset.rstrip("/").split("/")[-1].lower()
    return re.sub(r"[^a-z0-9]+", "-", tail).strip("-")[:24] or "dataset"


def _text_column(features):
    """Column to fit on: prefer ``text``, else the first string-valued column."""
    from datasets import Value

    if "text" in features:
        return "text"
    for name, feat in features.items():
        if isinstance(feat, Value) and feat.dtype == "string":
            return name
    raise ValueError(
        "dataset exposes no text column to fit on "
        f"(columns: {', '.join(features) or 'none'})"
    )


def _pack(texts, count, target=350):
    """Pack ``texts`` into ~``target``-char sequences, stopping as soon as
    ``count`` sequences are ready. jlens skips the first 16 positions of every
    sequence as attention sinks, so short unpacked prompts would almost all be
    dropped as too short. Returns fewer than ``count`` only if ``texts`` runs
    out (the caller decides whether that is an error)."""
    packs, cur = [], ""
    for text in texts:
        text = (text or "").strip()
        if not text:
            continue
        cur = f"{cur}\n\n{text}" if cur else text
        if len(cur) >= target:
            packs.append(cur)
            cur = ""
            if len(packs) >= count:
                return packs
    if cur and len(packs) < count:
        # trailing remainder: keep it so a just-large-enough dataset still fills
        # its quota (jlens tolerates a slightly-short final sequence)
        packs.append(cur)
    return packs


def _load_split(dataset):
    """``dataset``'s ``train`` split, or its first split if it has no ``train``."""
    from datasets import load_dataset

    try:
        return load_dataset(dataset, split="train")
    except ValueError:
        dd = load_dataset(dataset)
        return dd[next(iter(dd))]


def _load_one(dataset, n, skip):
    """``n`` training SEQUENCES from a single ``dataset`` id, skipping the first
    ``skip`` (continue-from: the new sequences must not overlap the base lens's).

    wikitext keeps its historical path (first records >=600 chars, streamed —
    one record already is one sequence). Any other HF dataset is loaded whole,
    its text column shuffled with a fixed seed, then PACKED into ~350-char
    sequences until skip+n are ready (median instruct prompt ~10 tokens, so
    several rows per sequence). ``n``/``skip`` count OUTPUT sequences, so the
    number the user asks for is exactly what the fit iterates over — not source
    rows, whose count varies per dataset."""
    if n <= 0:
        return []
    if dataset == DATASET_WIKITEXT:
        from jlens.examples import load_wikitext_prompts

        # load skip + n then keep the tail: the new sequences don't overlap the
        # base lens's
        return load_wikitext_prompts(skip + n)[skip:]
    import random

    ds = _load_split(dataset)
    col = _text_column(ds.features)
    texts = [r[col] for r in ds]
    random.Random(_SAMPLE_SEED).shuffle(texts)
    packs = _pack(texts, skip + n)
    if len(packs) < skip + n:
        raise ValueError(
            f"{dataset}: {len(texts)} rows pack into only {len(packs)} sequences, "
            f"{skip + n} requested — lower n_prompts"
        )
    return packs[skip:skip + n]


def _load_corpus(datasets, n, skip=0):
    """``n`` training SEQUENCES drawn from ``datasets`` (a list of HF dataset
    ids). A single id loads that dataset; several are mixed in EQUAL parts — n
    and skip are each split across them (the first datasets take the rounding
    remainder) and the union is shuffled so multi-GPU slices stay mixed. Because
    the count is in sequences, ``n`` is exactly what the fit iterates over."""
    datasets = list(datasets)
    if len(datasets) == 1:
        return _load_one(datasets[0], n, skip)
    import random

    k = len(datasets)
    prompts = []
    for i, ds in enumerate(datasets):
        prompts += _load_one(ds, n // k + int(i < n % k), skip // k + int(i < skip % k))
    random.Random(_SAMPLE_SEED).shuffle(prompts)
    return prompts

def _default_dim_batch(device):
    """Default dim_batch scaled to the device's VRAM.
    Measured on a 4B bf16 fit: 8 fits in 16 GB, 4 in 12 GB."""
    try:
        total = gpu_stats()[int(device.split(":")[1])]["vram_total"]
        return 8 if total >= 15 * 2**30 else 4
    except Exception:
        return 4


def _now():
    return datetime.now(timezone.utc).isoformat(timespec="seconds")


class FitManager:
    def __init__(self):
        self._lock = threading.Lock()
        self._procs = []
        self.state = {"state": "idle"}
        self.on_progress = None

    def _emit(self):
        if self.on_progress:
            self.on_progress(dict(self.state))

    def start(self, *, model_id, source, n_prompts=100, dtype="bf16", quant=None,
              devices=("cuda:0",), name=None, dim_batch=None,
              max_seq_len=128, source_layers=None, model_revision=None,
              continue_from=None, datasets=(DATASET_WIKITEXT,)):
        with self._lock:
            if self.state.get("state") == "running":
                raise ValueError("a fitting is already in progress")
            if not devices:
                raise ValueError("at least one device required")
            datasets = [d.strip() for d in datasets if d and d.strip()]
            if not datasets:
                raise ValueError("at least one dataset required")
            skip_prompts = 0
            base_lens = None
            if continue_from:
                base_lens = JacobianLens.load(continue_from)
                # new prompts: skip those already seen by the base lens
                skip_prompts = base_lens.n_prompts
                if source_layers is None:
                    source_layers = list(base_lens.source_layers)
            if name is None:
                base = model_id.split("/")[-1]
                if len(datasets) > 1:
                    base += "_mixed"
                elif datasets[0] != DATASET_WIKITEXT:
                    base += "_" + _slug(datasets[0])
                total = n_prompts + skip_prompts
                name = f"{base}_n{total}" if continue_from else f"{base}_n{n_prompts}"
            params = {
                "model_id": model_id,
                "source": source,
                "model_revision": model_revision,
                "dtype": dtype,
                "quant": quant,
                "n_prompts": n_prompts,
                "datasets": datasets,
                "devices": list(devices),
                "dim_batch": dim_batch,
                "max_seq_len": max_seq_len,
                "source_layers": source_layers,
                "continue_from": continue_from,
                "skip_prompts": skip_prompts,
            }
            self.state = {
                "state": "running",
                "name": name,
                "phase": "corpus",
                "total": n_prompts,
                "done": 0,
                "workers": [],
                "eta_seconds": None,
                "started_at": _now(),
                "params": params,
                "error": None,
            }
            self._procs = []
        threading.Thread(target=self._run, args=(name, params), daemon=True).start()
        return dict(self.state)

    def stop(self):
        with self._lock:
            for proc in self._procs:
                if proc.poll() is None:
                    proc.terminate()
            if self.state.get("state") == "running":
                self.state["state"] = "stopping"
        self._emit()
        return dict(self.state)

    def _run(self, name, params):
        try:
            job_dir = FITS_DIR / name
            job_dir.mkdir(parents=True, exist_ok=True)
            corpus_path = job_dir / "corpus.json"
            if corpus_path.exists():
                prompts = json.loads(corpus_path.read_text(encoding="utf-8"))
            else:
                prompts = _load_corpus(
                    params.get("datasets", [DATASET_WIKITEXT]),
                    params["n_prompts"],
                    params.get("skip_prompts", 0),
                )
                corpus_path.write_text(
                    json.dumps(prompts, ensure_ascii=False), encoding="utf-8"
                )
            devices = params["devices"]
            n = len(prompts)
            if len(devices) == 2:
                cut = int(n * 0.65)
                slices = [prompts[:cut], prompts[cut:]]
            else:
                slices = [prompts]

            self.state.update(phase="fitting", total=n)
            workers = []
            started = time.perf_counter()
            for i, (device, chunk) in enumerate(zip(devices, slices)):
                slice_path = job_dir / f"slice{i}.json"
                if not slice_path.exists():
                    slice_path.write_text(json.dumps(chunk, ensure_ascii=False), encoding="utf-8")
                dim_batch = params["dim_batch"] or _default_dim_batch(device)
                cmd = [
                    sys.executable, "-X", "utf8", str(WORKER),
                    "--model", params["source"],
                    "--device", device,
                    "--dtype", params["dtype"],
                    "--prompts", str(slice_path),
                    "--checkpoint", str(job_dir / f"ckpt{i}.pt"),
                    "--out", str(job_dir / f"lens{i}.pt"),
                    "--dim-batch", str(dim_batch),
                    "--max-seq-len", str(params["max_seq_len"]),
                ]
                if params["quant"]:
                    cmd += ["--quant", params["quant"]]
                if params["source_layers"]:
                    cmd += ["--source-layers", json.dumps(params["source_layers"])]
                proc = subprocess.Popen(
                    cmd,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    encoding="utf-8",
                    cwd=str(config.ROOT),
                )
                self._procs.append(proc)
                worker_state = {
                    "device": device,
                    "done": 0,
                    "total": len(chunk),
                    "dim_batch": dim_batch,
                    "state": "loading",
                    "elapsed": 0.0,
                    # [done, elapsed] of the last 10 updates: the ETA follows the
                    # RECENT pace (throughput can degrade mid-fit, e.g. VRAM
                    # saturated — a global average would then freeze the ETA)
                    "hist": [],
                }
                workers.append(worker_state)
                threading.Thread(
                    target=self._read_worker, args=(proc, worker_state, started), daemon=True
                ).start()
            self.state["workers"] = workers
            self._emit()

            stderr_tails = [""] * len(self._procs)

            def drain_err(index, proc):
                data = proc.stderr.read()
                stderr_tails[index] = (data or "")[-2000:]

            drainers = [
                threading.Thread(target=drain_err, args=(i, p), daemon=True)
                for i, p in enumerate(self._procs)
            ]
            for t in drainers:
                t.start()
            for proc in self._procs:
                proc.wait()
            for t in drainers:
                t.join()
            failed = [i for i, p in enumerate(self._procs) if p.returncode != 0]
            if self.state.get("state") == "stopping":
                self.state.update(state="stopped")
                self._emit()
                return
            if failed:
                detail = " | ".join(stderr_tails[i].strip().splitlines()[-1] if stderr_tails[i].strip() else "?" for i in failed)
                raise RuntimeError(f"worker(s) {failed} failed: {detail}")

            self.state.update(phase="merge")
            self._emit()
            partials = [
                JacobianLens.load(str(job_dir / f"lens{i}.pt"))
                for i in range(len(slices))
            ]
            merged = JacobianLens.merge(partials) if len(partials) > 1 else partials[0]
            if params.get("continue_from"):
                base_lens = JacobianLens.load(params["continue_from"])
                if base_lens.source_layers != merged.source_layers:
                    raise RuntimeError(
                        "cannot continue: the source layers differ from the base lens "
                        f"({base_lens.source_layers[0]}..{base_lens.source_layers[-1]} vs "
                        f"{merged.source_layers[0]}..{merged.source_layers[-1]})"
                    )
                # weighted average by n_prompts = equivalent to a fit over the union
                merged = JacobianLens.merge([base_lens, merged])
            out_dir = config.LENSES_DIR / name
            out_dir.mkdir(parents=True, exist_ok=True)
            lens_path = out_dir / "lens.pt"
            merged.save(str(lens_path))
            meta = {
                "name": name,
                "model_id": params["model_id"],
                "model_revision": params["model_revision"],
                "model_source": params["source"],
                "d_model": merged.d_model,
                "source_layers": [merged.source_layers[0], merged.source_layers[-1]],
                "dtype": params["dtype"],
                "quant": params["quant"],
                "n_prompts": merged.n_prompts,
                "corpus": (
                    "mixed: " + " + ".join(params["datasets"]) + " (equal parts)"
                    if len(params["datasets"]) > 1
                    else params["datasets"][0]
                ),
                "max_seq_len": params["max_seq_len"],
                "devices": params["devices"],
                "continued_from": params.get("continue_from"),
                "config_hash": hashlib.sha1(
                    json.dumps(params, sort_keys=True).encode()
                ).hexdigest()[:16],
                "created_at": _now(),
                "fit_seconds": round(time.perf_counter() - started, 1),
            }
            (out_dir / "meta.json").write_text(
                json.dumps(meta, ensure_ascii=False, indent=1), encoding="utf-8"
            )
            self.state.update(
                state="done",
                phase="done",
                lens_path=str(lens_path),
                meta=meta,
                eta_seconds=0,
            )
            self._emit()
        except Exception as exc:
            self.state.update(state="error", error=str(exc))
            self._emit()

    def _read_worker(self, proc, worker_state, started):
        for line in proc.stdout:
            line = line.strip()
            if not line:
                continue
            try:
                event = json.loads(line)
            except json.JSONDecodeError:
                continue
            if event["event"] == "loading":
                worker_state["state"] = "loading"
            elif event["event"] in ("progress", "resume"):
                worker_state["state"] = "fitting"
                worker_state["done"] = event["done"]
                worker_state["total"] = event["total"]
                worker_state["elapsed"] = round(time.perf_counter() - started, 1)
                hist = worker_state.setdefault("hist", [])
                hist.append([worker_state["done"], worker_state["elapsed"]])
                del hist[:-10]
            elif event["event"] == "done":
                worker_state["state"] = "done"
                worker_state["done"] = worker_state["total"]
            self._refresh_totals(started)
            self._emit()

    def _refresh_totals(self, started):
        workers = self.state.get("workers", [])
        self.state["done"] = sum(w["done"] for w in workers)
        etas = []
        for w in workers:
            if not (w["done"] > 0 and w["elapsed"] > 0 and w["done"] < w["total"]):
                continue
            hist = w.get("hist") or []
            if len(hist) >= 2 and hist[-1][1] > hist[0][1] and hist[-1][0] > hist[0][0]:
                # pace over the last 10 updates (sliding window)
                rate = (hist[-1][0] - hist[0][0]) / (hist[-1][1] - hist[0][1])
            else:
                rate = w["done"] / w["elapsed"]
            etas.append((w["total"] - w["done"]) / rate)
        # multi-GPU: the fit ETA = the slowest worker
        self.state["eta_seconds"] = round(max(etas), 0) if etas else None
        self.state["vram"] = [
            {"index": g["index"], "used_gb": round(g["vram_used"] / 2**30, 1)}
            for g in gpu_stats()
        ]
