from __future__ import annotations

import time
from dataclasses import dataclass, asdict
from typing import Any
from uuid import uuid4

from tools._logging import emit_plugin_stream_event


# ── Job capability descriptor ─────────────────────────────────────────────

@dataclass(frozen=True)
class JobCapabilities:
    """Declarative descriptor of what a job stream supports.

    Emitted inside metadata.capabilities so the dashboard can adapt its UI
    generically (show/hide progress bar, pause button, metrics chart, etc.)
    without per-plugin_id branching.
    """
    supports_cancel: bool = False
    supports_pause: bool = False
    supports_progress: bool = False
    supports_metrics: bool = False
    supports_retry: bool = False
    supports_priority: bool = False
    supports_phases: bool = False
    supports_stream_logs: bool = False
    supports_eta: bool = False
    supports_partial_results: bool = False
    supports_artifacts: bool = False
    supports_resume: bool = False
    supports_batch_items: bool = False

    def to_dict(self) -> dict[str, bool]:
        return asdict(self)


def make_capabilities(
    *,
    cancel: bool = False,
    pause: bool = False,
    progress: bool = False,
    metrics: bool = False,
    retry: bool = False,
    priority: bool = False,
    phases: bool = False,
    stream_logs: bool = False,
    eta: bool = False,
    partial_results: bool = False,
    artifacts: bool = False,
    resume: bool = False,
    batch_items: bool = False,
) -> JobCapabilities:
    """Convenience factory — pass only the flags you need."""
    return JobCapabilities(
        supports_cancel=cancel,
        supports_pause=pause,
        supports_progress=progress,
        supports_metrics=metrics,
        supports_retry=retry,
        supports_priority=priority,
        supports_phases=phases,
        supports_stream_logs=stream_logs,
        supports_eta=eta,
        supports_partial_results=partial_results,
        supports_artifacts=artifacts,
        supports_resume=resume,
        supports_batch_items=batch_items,
    )


@dataclass(frozen=True)
class JobMetricDef:
    """Metadata for a metric series rendered in the dashboard."""

    key: str
    label: str
    unit: str = ""
    format: str = "number"  # number | percent | bytes | duration_s | rate
    preferred_chart: str = "line"  # line | bar | area
    priority: int = 100

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)


def metric_def(
    key: str,
    label: str,
    *,
    unit: str = "",
    format: str = "number",
    preferred_chart: str = "line",
    priority: int = 100,
) -> JobMetricDef:
    return JobMetricDef(
        key=str(key or "").strip(),
        label=str(label or key).strip(),
        unit=str(unit or ""),
        format=str(format or "number"),
        preferred_chart=str(preferred_chart or "line"),
        priority=int(priority),
    )


def normalize_metric_schema(metric_schema: list[JobMetricDef | dict[str, Any]] | None) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    seen: set[str] = set()
    if not isinstance(metric_schema, list):
        return out
    for entry in metric_schema:
        raw = entry.to_dict() if isinstance(entry, JobMetricDef) else (entry if isinstance(entry, dict) else None)
        if not raw:
            continue
        key = str(raw.get("key") or "").strip()
        if not key or key in seen:
            continue
        seen.add(key)
        out.append(
            {
                "key": key,
                "label": str(raw.get("label") or key).strip(),
                "unit": str(raw.get("unit") or ""),
                "format": str(raw.get("format") or "number"),
                "preferred_chart": str(raw.get("preferred_chart") or "line"),
                "priority": int(raw.get("priority") if isinstance(raw.get("priority"), (int, float)) else 100),
            }
        )
    return out


# ── Shared control-value helpers (single source of truth) ─────────────────


def coerce_number(value: Any) -> float | None:
    """Try to coerce *value* to a float, returning None on failure."""
    if isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        try:
            return float(value.strip())
        except Exception:
            return None
    return None


def validate_control_value(
    control_def: dict,
    value: Any,
    *,
    action_id: str | None = None,
) -> tuple[bool, Any, dict | None, str | None, str | None]:
    """Validate *value* against *control_def*.

    Returns (ok, coerced_value, control_def, error_message, control_type).
    """
    ctype = str(control_def.get("type") or "").strip().lower()
    cid = str(control_def.get("id") or "").strip()

    if ctype == "slider":
        n = coerce_number(value)
        if n is None:
            return False, None, control_def, "Slider value must be numeric.", ctype
        min_v = coerce_number(control_def.get("min"))
        max_v = coerce_number(control_def.get("max"))
        step_v = coerce_number(control_def.get("step")) or 1.0
        if min_v is None or max_v is None or max_v <= min_v:
            return False, None, control_def, "Invalid slider definition.", ctype
        n = min(max(n, min_v), max_v)
        if step_v > 0:
            steps = round((n - min_v) / step_v)
            n = min(max(min_v + (steps * step_v), min_v), max_v)
        return True, float(n), control_def, None, ctype

    if ctype == "toggle":
        if isinstance(value, bool):
            return True, value, control_def, None, ctype
        if isinstance(value, str):
            low = value.strip().lower()
            if low in {"true", "1", "yes", "on"}:
                return True, True, control_def, None, ctype
            if low in {"false", "0", "no", "off"}:
                return True, False, control_def, None, ctype
        return False, None, control_def, "Toggle value must be true/false.", ctype

    if ctype == "select":
        candidate = str(value or "").strip()
        options = [str(o) for o in (control_def.get("options") or [])]
        if candidate in options:
            return True, candidate, control_def, None, ctype
        return False, None, control_def, "Select value must match one of the options.", ctype

    if ctype == "button":
        expected = str(control_def.get("action_id") or cid).strip()
        provided = str(action_id or expected).strip()
        if expected and provided and expected != provided:
            return False, None, control_def, f"Button action_id mismatch (expected '{expected}').", ctype
        return True, True, control_def, None, ctype

    return False, None, control_def, f"Unsupported control type '{ctype}'.", ctype


def find_control_def(controls: list[dict] | None, control_id: str) -> dict | None:
    """Look up a control definition by id from a list of control defs."""
    for c in (controls or []):
        if isinstance(c, dict) and str(c.get("id") or "").strip() == control_id:
            return c
    return None


def _infer_capabilities(controls: list[dict[str, Any]] | None) -> JobCapabilities:
    """Best-effort auto-detect capabilities from the declared controls."""
    cancel = False
    pause = False
    if isinstance(controls, list):
        for c in controls:
            if not isinstance(c, dict):
                continue
            aid = str(c.get("action_id") or c.get("id") or "").lower()
            cid = str(c.get("id") or "").lower()
            if aid in {"cancel_job", "stop_task", "stop_now", "cancel"} or cid in {"cancel", "stop_now", "stop_task"}:
                cancel = True
            if cid in {"pause_updates", "pause"} or aid in {"pause", "pause_updates"}:
                pause = True
    return JobCapabilities(supports_cancel=cancel, supports_pause=pause)


def open_job_stream(
    *,
    plugin_id: str,
    plugin_name: str,
    title: str,
    job_type: str,
    thread_id: str | None = None,
    stream_id: str | None = None,
    stream_type: str = "runtime",
    metadata: dict[str, Any] | None = None,
    controls: list[dict[str, Any]] | None = None,
    capabilities: JobCapabilities | None = None,
    metric_schema: list[JobMetricDef | dict[str, Any]] | None = None,
) -> str:
    sid = (stream_id or "").strip() or f"{plugin_id}:{job_type}:{uuid4().hex[:10]}"
    md = dict(metadata or {})
    md.setdefault("job_type", job_type)
    md.setdefault("schema", "mugi.job_stream.v1")
    md.setdefault("schema_version", 1)
    if isinstance(controls, list) and controls:
        md["controls"] = controls
    if capabilities is not None:
        md["capabilities"] = capabilities.to_dict()
    elif "capabilities" not in md:
        md["capabilities"] = _infer_capabilities(controls).to_dict()
    if "metric_schema" not in md:
        normalized_metrics = normalize_metric_schema(metric_schema)
        if normalized_metrics:
            md["metric_schema"] = normalized_metrics
    emit_plugin_stream_event(
        {
            "event": "open",
            "plugin_id": plugin_id,
            "plugin_name": plugin_name,
            "stream_id": sid,
            "stream_type": stream_type or "runtime",
            "title": title,
            "thread_id": thread_id,
            "metadata": md,
        }
    )
    return sid


def update_job_stream(
    *,
    plugin_id: str,
    plugin_name: str,
    stream_id: str,
    message: str,
    status: str = "running",
    phase: str | None = None,
    progress_pct: float | None = None,
    metrics: dict[str, float] | None = None,
    extra: dict[str, Any] | None = None,
    thread_id: str | None = None,
) -> None:
    payload: dict[str, Any] = {
        "status": status,
        "message": message,
        "metrics": dict(metrics or {}),
    }
    if phase:
        payload["phase"] = phase
    if progress_pct is not None:
        payload["progress_pct"] = float(max(0.0, min(100.0, progress_pct)))
    if extra:
        payload.update(extra)
    emit_plugin_stream_event(
        {
            "event": "update",
            "plugin_id": plugin_id,
            "plugin_name": plugin_name,
            "stream_id": stream_id,
            "thread_id": thread_id,
            "timestamp_ms": int(time.time() * 1000),
            "data": payload,
        }
    )


def close_job_stream(
    *,
    plugin_id: str,
    plugin_name: str,
    stream_id: str,
    status: str,
    message: str,
    metrics: dict[str, float] | None = None,
    extra: dict[str, Any] | None = None,
    thread_id: str | None = None,
) -> None:
    payload: dict[str, Any] = {
        "status": status,
        "message": message,
        "metrics": dict(metrics or {}),
    }
    if extra:
        payload.update(extra)
    emit_plugin_stream_event(
        {
            "event": "close",
            "plugin_id": plugin_id,
            "plugin_name": plugin_name,
            "stream_id": stream_id,
            "thread_id": thread_id,
            "status": status,
            "timestamp_ms": int(time.time() * 1000),
            "data": payload,
        }
    )


def error_job_stream(
    *,
    plugin_id: str,
    plugin_name: str,
    stream_id: str,
    message: str,
    phase: str | None = None,
    extra: dict[str, Any] | None = None,
    thread_id: str | None = None,
) -> None:
    payload: dict[str, Any] = {"status": "error", "message": message}
    if phase:
        payload["phase"] = phase
    if extra:
        payload.update(extra)
    emit_plugin_stream_event(
        {
            "event": "error",
            "plugin_id": plugin_id,
            "plugin_name": plugin_name,
            "stream_id": stream_id,
            "thread_id": thread_id,
            "timestamp_ms": int(time.time() * 1000),
            "message": message,
            "data": payload,
        }
    )
