import uuid
import json
import os
import threading
from typing import Any, Dict, Optional
from fastapi import FastAPI, File, Form, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse
from pydantic import BaseModel


class _UTF8JSONResponse(JSONResponse):
    """FastAPI's default JSONResponse uses ensure_ascii=True, which escapes
    Cyrillic and other non-ASCII characters to \\uXXXX sequences.
    This subclass disables that so error messages stay human-readable."""

    def render(self, content: Any) -> bytes:
        return json.dumps(
            content,
            ensure_ascii=False,
            allow_nan=False,
            indent=None,
            separators=(",", ":"),
        ).encode("utf-8")
from .scenario import Scenario, Step, StepStatus
from .docstore import JobDoc
from .scenario_loader import load_scenarios
from .signal_decoder import decode_measurement


class LoadRequest(BaseModel):
    param_overrides: Optional[Dict[str, Any]] = None
    # Format: {step_name: {param_key: value}}
    # Example: {"start_measurement": {"info": {...}}}


class ModeRequest(BaseModel):
    mode: str  # "real" or "plug"


class StepSpec(BaseModel):
    name: str
    params: Optional[Dict[str, Any]] = None


class CreateScenarioRequest(BaseModel):
    steps: list[StepSpec]

# Выбор задач: боевой/заглушки
mode = os.getenv("MODE", "stub")
if mode == "real":
    from . import tasks_real as tasks
else:
    from . import tasks_plug as tasks

TASK_REGISTRY = tasks.TASK_REGISTRY

# Runtime mode tracking (can be changed via POST /mode while the process runs)
_current_mode: str = "real" if mode == "real" else "plug"
_mode_lock = threading.Lock()

app = FastAPI(title="Orchestrator with Templates",
              default_response_class=_UTF8JSONResponse)


@app.get("/health")
def health():
    return {"status": "ok"}


@app.get("/tasks")
def list_tasks():
    """Return names of all registered task functions."""
    return {"tasks": list(TASK_REGISTRY.keys())}


@app.post("/scenario/create")
def create_scenario(body: CreateScenarioRequest):
    """
    Create a job from an inline step list — no YAML template required.

    Steps are validated against the current TASK_REGISTRY.
    Returns {"job_id": "..."} immediately, same as /scenario/load/{id}.
    """
    for spec in body.steps:
        if spec.name not in TASK_REGISTRY:
            raise HTTPException(
                status_code=400,
                detail=f"Unknown task: '{spec.name}'. "
                       f"Available: {sorted(TASK_REGISTRY.keys())}",
            )

    job_id = str(uuid.uuid4())
    tpl = {"id": "custom", "steps": [
        {"name": s.name, "params": s.params or {}} for s in body.steps
    ]}
    scenario = build_scenario_from_template(tpl)
    SCENARIOS[job_id] = scenario

    doc = JobDoc(
        id=job_id,
        scenario={"steps": [step_to_dict(s) for s in scenario.steps]},
    )
    doc.save()
    return {"job_id": job_id}


@app.get("/mode")
def get_mode_endpoint():
    """Return the currently active task mode ('real' or 'plug')."""
    return {"mode": _current_mode}


@app.post("/mode")
def set_mode_endpoint(body: ModeRequest):
    """
    Switch the active task registry at runtime.

    Accepts mode = 'real' | 'plug'.
    All subsequent scenario builds will use the new task implementations.
    Already-running jobs are not affected.
    """
    global TASK_REGISTRY, _current_mode
    new_mode = body.mode
    if new_mode not in ("real", "plug"):
        raise HTTPException(status_code=400, detail="mode must be 'real' or 'plug'")
    with _mode_lock:
        if new_mode == "real":
            from . import tasks_real as new_tasks
        else:
            from . import tasks_plug as new_tasks
        TASK_REGISTRY = new_tasks.TASK_REGISTRY
        _current_mode = new_mode
    return {"mode": _current_mode}


@app.get("/spectrometer/health")
def spectrometer_health():
    """Check connectivity to the spectrometer service."""
    spec_url  = os.getenv("SPECTROMETER_URL",      "http://localhost:1818")
    spec_user = os.getenv("SPECTROMETER_USER",     "admin")
    spec_pass = os.getenv("SPECTROMETER_PASSWORD", "admin")
    try:
        import requests
        r = requests.get(
            f"{spec_url}/api/",
            auth=(spec_user, spec_pass),
            timeout=5.0,
        )
        if r.status_code < 500:
            return {"status": "ok", "spectrometer_url": spec_url}
        raise HTTPException(
            status_code=503,
            detail=f"Spectrometer returned HTTP {r.status_code}",
        )
    except requests.ConnectionError as exc:
        raise HTTPException(status_code=503, detail=f"Spectrometer unreachable: {exc}")
    except requests.Timeout:
        raise HTTPException(status_code=503, detail="Spectrometer health check timed out")


# Память для живых Scenario (по job_id)
SCENARIOS: Dict[str, Scenario] = {}
# Кэш шаблонов сценариев
SCENARIO_TEMPLATES = load_scenarios()  # dict[id] = {"id": ..., "steps": [...]}


def step_to_dict(s: Step) -> dict:
    return {
        "name": s.name,
        "status": s.status.value if isinstance(s.status, StepStatus) else str(s.status),
        "params": s.params,
        "result": s.result,
    }


def build_scenario_from_template(tpl: dict) -> Scenario:
    """
    tpl: {"id": "...", "steps": [{"name": "...", "params": {...}}, ...]}
    """
    steps = []
    for item in tpl.get("steps", []):
        name = item["name"]
        params = item.get("params", {}) or {}
        if name not in TASK_REGISTRY:
            raise HTTPException(status_code=400, detail=f"Unknown task name: {name}")
        func = TASK_REGISTRY[name]
        steps.append(Step(name=name, func=func, params=params))
    return Scenario(steps=steps)


@app.get("/scenario/list")
def list_scenarios():
    # перечень доступных шаблонов
    return {"scenarios": list(SCENARIO_TEMPLATES.keys())}


@app.post("/scenario/load/{scenario_id}")
def load_scenario(scenario_id: str, body: LoadRequest = None):
    tpl = SCENARIO_TEMPLATES.get(scenario_id)
    if not tpl:
        raise HTTPException(status_code=404, detail="Scenario template not found")

    job_id = str(uuid.uuid4())
    scenario = build_scenario_from_template(tpl)

    # Apply per-step parameter overrides supplied by the caller (e.g. GUI info dict).
    # This is additive: template params are kept unless the same key is overridden.
    if body and body.param_overrides:
        for step in scenario.steps:
            if step.name in body.param_overrides:
                step.params.update(body.param_overrides[step.name])

    # сохраняем живой Scenario в память
    SCENARIOS[job_id] = scenario

    # создаём первичный JobDoc (pending шаги)
    doc = JobDoc(
        id=job_id,
        scenario={"steps": [step_to_dict(s) for s in scenario.steps]},
    )
    doc.save()
    return {"job_id": job_id}


@app.post("/scenario/{job_id}/next")
def run_next(job_id: str):
    scenario = SCENARIOS.get(job_id)
    if not scenario:
        raise HTTPException(status_code=404, detail="Scenario not found")

    if scenario.current >= len(scenario.steps):
        raise HTTPException(status_code=400, detail="No more steps to run")

    step = scenario.run_next()

    # сохраняем прогресс
    doc = JobDoc(id=job_id, scenario={"steps": [step_to_dict(s) for s in scenario.steps]})
    doc.save()

    return {"step": step_to_dict(step), "context": scenario.context}


@app.post("/scenario/{job_id}/run_all")
def run_all(job_id: str):
    scenario = SCENARIOS.get(job_id)
    if not scenario:
        raise HTTPException(status_code=404, detail="Scenario not found")

    scenario.run_all()

    doc = JobDoc(id=job_id, scenario={"steps": [step_to_dict(s) for s in scenario.steps]})
    doc.save()
    return {"status": "done", "steps": [step_to_dict(s) for s in scenario.steps]}


@app.get("/scenario/{job_id}")
def get_status(job_id: str):
    # читаем последнее сохранённое состояние
    doc = JobDoc.load(job_id)
    if not doc:
        raise HTTPException(status_code=404, detail="Scenario not found")
    return doc.scenario


def _build_scenario_for_job(
    tpl: dict,
    seq_info: dict,
    protocol: str,
) -> Scenario:
    """
    Build a Scenario from template, injecting seq_info + protocol into
    the start_measurement step params.  Raises ValueError for unknown tasks.
    """
    steps = []
    for item in tpl.get("steps", []):
        name = item["name"]
        params = dict(item.get("params", {}) or {})
        if name not in TASK_REGISTRY:
            raise ValueError(f"Unknown task name in template: {name!r}")
        if name == "start_measurement":
            if seq_info:
                params["info"] = {**params.get("info", {}), **seq_info}
            if protocol:
                params["protocol"] = protocol
        steps.append(Step(name=name, func=TASK_REGISTRY[name], params=params))
    return Scenario(steps=steps)


_SEQ_OUTPUT_HOST_PATH = os.getenv("SEQ_OUTPUT_HOST_PATH", "").strip()


def _patch_seq_file_paths(seq_info: dict, container_output_dir: str) -> None:
    """
    Replace bare filenames in seq_info device configs with absolute host paths.

    seq-interp writes files into container_output_dir (e.g. data/output/<task_id>).
    That directory is bind-mounted to SEQ_OUTPUT_HOST_PATH/<task_id> on the Windows
    host, so the native spectrometer can read the generated XML / bin / txt files.

    Does nothing when SEQ_OUTPUT_HOST_PATH is not configured.
    """
    if not _SEQ_OUTPUT_HOST_PATH or not container_output_dir:
        return
    task_subdir = os.path.basename(container_output_dir.rstrip("/\\"))
    host_dir = os.path.join(_SEQ_OUTPUT_HOST_PATH, task_subdir)
    for dev_key in ("isync", "isdr", "igrax", "igray", "igraz"):
        dev = seq_info.get(dev_key)
        if isinstance(dev, dict) and dev.get("file"):
            dev["file"] = os.path.join(host_dir, os.path.basename(dev["file"]))


def _run_scan_background(
    job_id: str,
    file_bytes: bytes | None,
    filename: str | None,
    seq_info_json: str | None,
    scenario_id: str,
    protocol: str,
) -> None:
    """
    Background thread: interpret .seq (if provided) → build scenario → run_all.
    Saves job state to DocStore at each major step so the GUI can poll it.
    """
    def _save(status: str, steps: list | None = None) -> None:
        payload: dict = {"status": status}
        if steps is not None:
            payload["steps"] = steps
        JobDoc(id=job_id, scenario=payload).save()

    try:
        # 1. Interpret .seq file via seq-interp (if provided)
        seq_info: dict = {}
        if file_bytes and filename:
            _save("interpreting")
            seq_interp_url = os.getenv("SEQ_INTERP_URL", "http://seq-interp:7475")
            from .clients.seq_interp_cl import SeqInterpClient, SeqInterpError
            client = SeqInterpClient(seq_interp_url)
            result = client.interpret_and_wait(file_bytes, filename)
            post_json = result.get("post_json", {})
            seq_info = post_json.get("info", post_json) or {}
            # Patch file paths so Windows-native spectrometer can find the artifacts
            _patch_seq_file_paths(seq_info, result.get("output_dir", ""))
        elif seq_info_json:
            seq_info = json.loads(seq_info_json)

        # 2. Build scenario
        tpl = SCENARIO_TEMPLATES.get(scenario_id)
        if not tpl:
            _save(f"failed: unknown scenario '{scenario_id}'")
            return

        scenario = _build_scenario_for_job(tpl, seq_info, protocol)
        SCENARIOS[job_id] = scenario

        _save("running", [step_to_dict(s) for s in scenario.steps])

        # 3. Run all steps (blocking); scenario catches per-step exceptions internally
        scenario.run_all()

        _save("done", [step_to_dict(s) for s in scenario.steps])

    except Exception as exc:
        JobDoc(id=job_id, scenario={"status": f"failed: {exc}", "steps": []}).save()


@app.post("/scan/")
async def scan_endpoint(
    file: UploadFile = File(None),
    seq_info_json: str = Form(None),
    scenario_id: str = Form("full_pipeline"),
    protocol: str = Form(""),
):
    """
    Single entry point for the GUI scan button.

    Accepts either:
      - a .seq file (multipart) → orchestrator forwards to seq-interp for interpretation
      - seq_info_json (form field, JSON string) → already-interpreted parameters

    Returns {"job_id": "..."} immediately; the pipeline runs in a background thread.
    Poll GET /scenario/{job_id} for status / step results.
    """
    job_id = str(uuid.uuid4())
    file_bytes = await file.read() if file else None
    filename = file.filename if (file and file.filename) else None

    # Register job immediately so polling can start right away
    JobDoc(id=job_id, scenario={"status": "queued", "steps": []}).save()

    threading.Thread(
        target=_run_scan_background,
        args=(job_id, file_bytes, filename, seq_info_json, scenario_id, protocol),
        daemon=True,
        name=f"scan-{job_id[:8]}",
    ).start()

    return {"job_id": job_id}


@app.get("/measurement/{meas_id}/decode")
def decode_measurement_endpoint(
    meas_id: int,
    averaging_num: int = Query(default=1, ge=1),
    data_num: int = Query(default=1, ge=1),
    channel_num: int = Query(default=0, ge=0),
    zero_fill: int = Query(default=0, ge=0),
    voltage_range: float = Query(default=0.02, gt=0),
):
    """
    Fetch raw measurement data from the spectrometer and return decoded
    signal (volts) + amplitude spectrum.
    """
    from .clients.spec_cl import SpectrometerClient

    spectrometer_url = os.getenv("SPECTROMETER_URL", "http://localhost:1818")
    spec_user = os.getenv("SPECTROMETER_USER", "admin")
    spec_pass = os.getenv("SPECTROMETER_PASSWORD", "admin")

    try:
        spec = SpectrometerClient(
            base_url=spectrometer_url,
            username=spec_user,
            password=spec_pass,
        )
    except Exception as e:
        raise HTTPException(status_code=503, detail=f"Cannot connect to spectrometer: {e}")

    try:
        raw = spec.get_data(meas_id)
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"Failed to fetch data from spectrometer: {e}")

    try:
        result = decode_measurement(
            raw_json=raw,
            averaging_num=averaging_num,
            data_num=data_num,
            channel_num=channel_num,
            zero_fill=zero_fill,
            voltage_range=voltage_range,
        )
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e))

    return result
