"""Fixed-invocation command builder — the enforcement point for INV-T7.

arbo hands the worker a set of BOUNDED parameters; this module turns them into the ONE
`accelerate launch sdxl_train_network.py …` command the worker is allowed to run. Every
parameter is validated (type, range, allowlist, path-containment) before a single argv
element is produced. There is NO path from an arbo request to a free-form argument — the
flag set is a constant, only the *values* of vetted parameters vary.

`build_command` is a pure function (no I/O, no subprocess) so it is trivially unit-testable:
feed it a request dict, assert the argv + env. `InvalidTrainRequest` maps to HTTP 422.
"""

from __future__ import annotations

import re
from pathlib import Path

from . import config

# Safe token for names that land in a filename / kohya folder path. No shell metachars,
# no path separators, no leading dot — even though we never use a shell (argv list), this
# also protects the on-disk paths built from `output_name` / `trigger` / `subject_class`.
_SAFE_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 _.\-]{0,63}$")
_SAFE_OUTPUT_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$")  # filename stem, no spaces


class InvalidTrainRequest(ValueError):
    """A train request failed validation → HTTP 422 (never a silent downgrade)."""


def _require(cond: bool, msg: str) -> None:
    if not cond:
        raise InvalidTrainRequest(msg)


def _validate_under(path_str: str, roots: tuple[Path, ...], field: str) -> Path:
    """Resolve `path_str` and require it to sit under one of `roots` (no traversal escape)."""
    _require(isinstance(path_str, str) and path_str.startswith("/"), f"{field} must be an absolute path")
    p = Path(path_str)
    # Reject traversal explicitly even before resolve() (belt): no '..' components.
    _require(".." not in p.parts, f"{field} must not contain '..'")
    resolved = p.resolve()
    for root in roots:
        try:
            resolved.relative_to(root.resolve())
            return resolved
        except ValueError:
            continue
    allowed = ", ".join(str(r) for r in roots)
    raise InvalidTrainRequest(f"{field} {resolved} is not under an allowed root ({allowed})")


def validate_request(req: dict) -> dict:
    """Validate + normalize an arbo /train request. Returns a clean param dict or raises."""
    # ---- tier -> (steps, dim, res) --------------------------------------------------
    tier_raw = req.get("tier")
    _require(tier_raw in config.TIERS, f"tier must be one of {sorted(config.TIERS)}; got {tier_raw!r}")
    tier = str(tier_raw)
    spec = config.TIERS[tier]

    # ---- device_index (int, 0=3090 / 1=A6000 under PCI_BUS_ID) ----------------------
    device_index = req.get("device_index")
    _require(isinstance(device_index, int) and device_index in (config.DEVICE_3090, config.DEVICE_A6000),
             f"device_index must be {config.DEVICE_3090} (3090) or {config.DEVICE_A6000} (A6000)")

    # ---- tier/device fit: quality (1024) is A6000-only (§4.6) -----------------------
    if tier == "quality":
        _require(device_index in config.QUALITY_ONLY_DEVICES,
                 "tier 'quality' (1024) requires the A6000 (device_index=1); the 3090 cannot fit it")

    # ---- names (safe tokens; used in argv AND on-disk paths) ------------------------
    trigger = req.get("trigger")
    subject_class = req.get("subject_class")
    output_name = req.get("output_name")
    _require(isinstance(trigger, str) and bool(_SAFE_TOKEN.match(trigger)), "trigger is not a safe token")
    _require(isinstance(subject_class, str) and bool(_SAFE_TOKEN.match(subject_class)),
             "subject_class is not a safe token")
    _require(isinstance(output_name, str) and bool(_SAFE_OUTPUT_NAME.match(output_name)),
             "output_name is not a safe filename stem")

    # ---- repeats + seed (bounded ints) ----------------------------------------------
    repeats = req.get("repeats")
    _require(isinstance(repeats, int) and 1 <= repeats <= 100, "repeats must be an int in [1, 100]")
    seed = req.get("seed", 42)
    _require(isinstance(seed, int) and 0 <= seed <= 2**31 - 1, "seed must be a non-negative int32")

    # ---- train_id (optional; arbo's id — the publish-path namespace, Phase 2) -------
    # Explicit over the derive-from-output_dir.parent fallback (decouples the worker from
    # arbo's handoff layout). Path-safe token since it lands in the loras/trained/{train_id}/ path.
    train_id = req.get("train_id")
    if train_id is not None:
        _require(isinstance(train_id, str) and bool(_SAFE_OUTPUT_NAME.match(train_id)),
                 "train_id is not a safe token")

    # ---- paths (containment-checked) ------------------------------------------------
    dataset_dir = _validate_under(req.get("dataset_dir", ""), (config.HANDOFF_ROOT,), "dataset_dir")
    output_dir = _validate_under(req.get("output_dir", ""), (config.HANDOFF_ROOT,), "output_dir")
    base_model_path = _validate_under(req.get("base_model_path", ""), config.ALLOWED_MODEL_ROOTS, "base_model_path")

    return {
        "tier": tier,
        "steps": spec["steps"],
        "dim": spec["dim"],
        "resolution": spec["resolution"],
        "device_index": device_index,
        "trigger": trigger,
        "subject_class": subject_class,
        "output_name": output_name,
        "repeats": repeats,
        "seed": seed,
        "dataset_dir": str(dataset_dir),
        "output_dir": str(output_dir),
        "base_model_path": str(base_model_path),
        "train_id": train_id,
    }


def published_relative_path(output_dir: str, output_name: str, train_id: str | None = None) -> str:
    """The ComfyUI-relative loras path for a succeeded LoRA (Phase 2 publish step).

    Uses the explicit `train_id` (arbo now sends it — decoupled from the path layout); falls
    back to deriving it from the handoff layout `HANDOFF_ROOT/{train_id}/output` (the name of the
    dir the LoRA was written into) when absent. Returns e.g. `trained/392cf898ac03/name.safetensors`,
    the exact string a ComfyUI `LoraLoader.lora_name` widget takes. Pure — no filesystem touch."""
    tid = train_id or Path(output_dir).parent.name
    return f"{config.PUBLISH_SUBDIR}/{tid}/{output_name}.safetensors"


def build_command(req: dict) -> tuple[list[str], dict[str, str], dict]:
    """Validate `req` and return `(argv, env_overlay, params)` for the fixed sd-scripts invocation.

    argv is a LIST (never a shell string) — no metachar interpretation is possible. env_overlay
    is merged onto os.environ by the caller (the CUDA ordering + allocator knobs from §4.6).
    `params` is the vetted, normalized parameter dict (so the caller need not re-validate).
    """
    p = validate_request(req)
    h = config.SDXL_HPARAMS
    batch = h["train_batch_size_full"] if p["device_index"] == config.DEVICE_A6000 else h["train_batch_size_lean"]
    alpha = int(round(p["dim"] * float(h["network_alpha_ratio"])))
    res = f"{p['resolution']},{p['resolution']}"

    argv = [
        str(config.ACCELERATE_BIN), "launch",
        "--num_processes", "1",
        "--num_cpu_threads_per_process", "1",
        "--mixed_precision", h["mixed_precision"],
        "--dynamo_backend", "no",
        str(config.SDXL_TRAIN_SCRIPT),
        "--pretrained_model_name_or_path", p["base_model_path"],
        "--train_data_dir", p["dataset_dir"],
        "--output_dir", p["output_dir"],
        "--output_name", p["output_name"],
        "--save_model_as", "safetensors",
        "--save_precision", h["save_precision"],
        "--mixed_precision", h["mixed_precision"],
        # LoRA network (§4.6 tier dim; unet-only per the lean recipe)
        "--network_module", "networks.lora",
        "--network_dim", str(p["dim"]),
        "--network_alpha", str(alpha),
        "--network_train_unet_only",
        # steps + resolution (§4.6 tier table)
        "--max_train_steps", str(p["steps"]),
        "--resolution", res,
        "--enable_bucket",
        "--train_batch_size", batch,
        # memory-lean base (§4.6 — the proven Sindra flag set)
        "--gradient_checkpointing",
        "--cache_latents", "--cache_latents_to_disk",
        "--cache_text_encoder_outputs", "--cache_text_encoder_outputs_to_disk",
        "--optimizer_type", "adamw8bit",
        "--no_half_vae",
        "--sdpa",
        "--caption_extension", ".txt",  # CRITICAL: sd-scripts defaults to .caption → would skip our captions
        # optimizer schedule (agent-discretion defaults; confirm vs Sindra)
        "--learning_rate", h["learning_rate"],
        "--lr_scheduler", h["lr_scheduler"],
        "--lr_warmup_steps", h["lr_warmup_steps"],
        "--min_snr_gamma", h["min_snr_gamma"],
        "--noise_offset", h["noise_offset"],
        "--max_data_loader_n_workers", h["max_data_loader_n_workers"],
        "--persistent_data_loader_workers",
        "--seed", str(p["seed"]),
    ]

    env_overlay = {
        "CUDA_DEVICE_ORDER": "PCI_BUS_ID",  # index 0=3090, 1=A6000 (recipe-pinned ordering)
        "CUDA_VISIBLE_DEVICES": str(p["device_index"]),
        "PYTORCH_CUDA_ALLOC_CONF": "expandable_segments:True",
    }
    return argv, env_overlay, p
