"""Shared Helm/kubectl helpers for Kubernetes-based recipes.

Every plan dataclass that targets a Kubernetes cluster exposes
``kubectl_flags()`` and ``helm_flags()`` methods plus ``release_name``
and ``namespace`` attributes.  The :class:`HelmTarget` protocol captures
this contract so the helpers below work with *any* plan type without
duplicating code in each recipe.
"""

from __future__ import annotations

import json
import shlex
from datetime import datetime, timezone
from typing import Protocol

import yaml

from k4s.core.executor import Executor, ExecutorError
from k4s.recipes.common.run import q, run
from k4s.ui.ui import Ui

MAX_DIAGNOSTIC_EVENTS = 10
DIAGNOSTIC_EVENT_WINDOW_MINUTES = 5


# ---------------------------------------------------------------------------
# Protocol – any plan that provides kubectl/helm flags
# ---------------------------------------------------------------------------

class HelmTarget(Protocol):
    """Structural type for plan objects used with Helm/kubectl helpers."""

    release_name: str
    namespace: str
    timeout: str

    def kubectl_flags(self) -> list[str]: ...
    def helm_flags(self) -> list[str]: ...


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def mask_secret(value: str | None) -> str:
    """Return a safe display string for a secret value."""
    return "<set>" if value else "<not set>"


def which(ex: Executor, bin_name: str) -> bool:
    """Check whether a binary is available on the PATH."""
    rc, _, _ = run(ex, f"command -v {q(bin_name)} >/dev/null 2>&1 || true")
    return rc == 0


def kube_cmd(plan: HelmTarget, binary: str, *args: str) -> str:
    """Build a kubectl or helm command string with kubeconfig/context flags."""
    if binary == "kubectl":
        base_flags = plan.kubectl_flags()
    elif binary == "helm":
        base_flags = plan.helm_flags()
    else:
        base_flags = []
    parts: list[str] = [binary] + base_flags + list(args)
    return " ".join(shlex.quote(p) for p in parts)


def release_exists(ex: Executor, plan: HelmTarget) -> bool:
    """Check whether a Helm release already exists in the target namespace."""
    cmd = (
        kube_cmd(plan, "helm", "status", plan.release_name, "-n", plan.namespace)
        + " >/dev/null 2>&1"
    )
    rc, _, _ = run(ex, cmd)
    return rc == 0


def _show_diagnostics(ui: Ui, ex: Executor, plan: HelmTarget):
    """Show pod status and deduplicated warning events for the target namespace.

    Used when a Helm timeout or rollout failure occurs to help diagnose the
    underlying Kubernetes issue (e.g. ``FailedMount``, resource limits).

    Events are deduplicated by (reason, involved object, message), filtered
    to the last few minutes, and capped at ``MAX_DIAGNOSTIC_EVENTS``.
    """
    # --- Pod status (compact) ---
    _, pods_out, _ = run(
        ex, kube_cmd(plan, "kubectl", "-n", plan.namespace, "get", "pods", "-o", "wide"),
        silent=True,
    )
    if pods_out.strip():
        ui.info(pods_out.strip())

    # --- Warning events (deduplicated, time-filtered) ---
    _, events_json, _ = run(
        ex,
        kube_cmd(
            plan, "kubectl", "-n", plan.namespace,
            "get", "events",
            "--field-selector", "type=Warning",
            "-o", "json",
        ),
        silent=True,
    )
    if not events_json.strip():
        return

    try:
        events_data = json.loads(events_json)
    except (json.JSONDecodeError, ValueError):
        return

    items = events_data.get("items", [])
    if not items:
        return

    now = datetime.now(timezone.utc)
    window_seconds = DIAGNOSTIC_EVENT_WINDOW_MINUTES * 60

    # Parse and filter events within the time window.
    parsed: list[dict] = []
    for ev in items:
        last_ts = (
            ev.get("lastTimestamp")
            or (ev.get("series") or {}).get("lastObservedTime")
            or ev.get("eventTime")
        )
        if last_ts:
            try:
                ts = datetime.fromisoformat(last_ts.replace("Z", "+00:00"))
                if (now - ts).total_seconds() > window_seconds:
                    continue
            except (ValueError, TypeError):
                pass

        reason = ev.get("reason", "Unknown")
        obj_kind = (ev.get("involvedObject") or {}).get("kind", "")
        obj_name = (ev.get("involvedObject") or {}).get("name", "")
        message = ev.get("message", "")
        count = ev.get("count", 1)

        parsed.append({
            "reason": reason,
            "object": f"{obj_kind}/{obj_name}" if obj_kind else obj_name,
            "message": message,
            "count": count,
            "last_ts": last_ts or "",
        })

    if not parsed:
        return

    # Deduplicate by (reason, message). Keep the highest count and latest timestamp.
    deduped: dict[tuple[str, str], dict] = {}
    for ev in parsed:
        key = (ev["reason"], ev["message"])
        if key in deduped:
            existing = deduped[key]
            existing["count"] = max(existing["count"], ev["count"])
            if ev["last_ts"] > existing["last_ts"]:
                existing["last_ts"] = ev["last_ts"]
                existing["object"] = ev["object"]
        else:
            deduped[key] = dict(ev)

    # Sort by timestamp descending (most recent first) and cap.
    sorted_events = sorted(deduped.values(), key=lambda e: e["last_ts"], reverse=True)
    display_events = sorted_events[:MAX_DIAGNOSTIC_EVENTS]

    ui.warning(
        f"Recent warning events (last {DIAGNOSTIC_EVENT_WINDOW_MINUTES}min, "
        f"showing {len(display_events)} of {len(sorted_events)} unique):"
    )
    for ev in display_events:
        count_str = f" (x{ev['count']})" if ev["count"] > 1 else ""
        ui.info(f"  [{ev['reason']}] {ev['object']}: {ev['message']}{count_str}")


def handle_helm_timeout(
    ui: Ui, ex: Executor, plan: HelmTarget, error: ExecutorError,
):
    """Show diagnostic kubectl info when Helm times out.

    Prints pod status and recent warning events to help diagnose the
    underlying Kubernetes failure (e.g. ``FailedMount``, resource limits).
    """
    msg = str(error)
    if "context deadline exceeded" in msg or "timed out waiting" in msg:
        ui.warning(
            "Helm timed out waiting for resources. "
            "Fetching warning events and pod status for diagnosis."
        )
        _show_diagnostics(ui, ex, plan)


def postcheck_pods(ui: Ui, ex: Executor, plan: HelmTarget):
    """Show pods in the target namespace (best-effort post-install/upgrade check)."""
    ui.log("Showing pods in the target namespace (best-effort).")
    rc, out, _ = run(
        ex, kube_cmd(plan, "kubectl", "-n", plan.namespace, "get", "pods", "-o", "wide"),
    )
    if rc == 0 and out:
        ui.info(out)


def _get_release_workloads(
    ui: Ui, ex: Executor, plan: HelmTarget,
) -> list[tuple[str, str]]:
    """Return (kind_lower, name) pairs for workloads in a Helm release.

    Reads the Helm release manifest and extracts Deployment, StatefulSet,
    and DaemonSet resources.  Returns an empty list on failure.
    """
    rc, manifest_out, manifest_err = run(
        ex,
        kube_cmd(
            plan,
            "helm",
            "get",
            "manifest",
            plan.release_name,
            "-n",
            plan.namespace,
        ),
        silent=True,
    )
    if rc != 0:
        ui.warning(
            "Cannot read Helm manifest. "
            f"helm error: {(manifest_err or manifest_out).strip()}"
        )
        return []

    workloads: list[tuple[str, str]] = []
    try:
        for doc in yaml.safe_load_all(manifest_out):
            if not isinstance(doc, dict):
                continue
            kind = doc.get("kind")
            if kind not in ("Deployment", "StatefulSet", "DaemonSet"):
                continue
            name = (doc.get("metadata") or {}).get("name")
            if not name:
                continue
            workloads.append((kind.lower(), str(name)))
    except Exception as e:
        ui.warning(f"Cannot parse Helm manifest. Error: {e}")
        return []

    # De-dup while preserving order.
    seen: set[tuple[str, str]] = set()
    return [w for w in workloads if not (w in seen or seen.add(w))]


def snapshot_release_pods(ui: Ui, ex: Executor, plan: HelmTarget) -> list[str]:
    """Return the names of all pods currently running for a Helm release.

    Reads the Helm manifest, discovers workloads, reads their selectors,
    and lists matching pods.  Designed to be called **before**
    ``helm upgrade`` so the caller can capture which pods are "old".
    """
    workloads = _get_release_workloads(ui, ex, plan)
    if not workloads:
        return []

    pods: list[str] = []
    for kind, name in workloads:
        rc, obj_out, _ = run(
            ex,
            kube_cmd(
                plan, "kubectl", "-n", plan.namespace,
                "get", kind, name, "-o", "json",
            ),
            silent=True,
        )
        if rc != 0:
            continue
        try:
            obj = json.loads(obj_out)
            labels = (
                (obj.get("spec") or {}).get("selector", {}).get("matchLabels") or {}
            )
            if not labels:
                continue
            selector = ",".join(f"{k}={v}" for k, v in labels.items())
            rc2, out2, _ = run(
                ex,
                kube_cmd(
                    plan, "kubectl", "-n", plan.namespace,
                    "get", "pods", "-l", selector, "-o", "name",
                ),
                silent=True,
            )
            if rc2 != 0:
                continue
            for line in out2.splitlines():
                pod = line.strip()
                if not pod:
                    continue
                if pod.startswith("pod/"):
                    pod = pod[len("pod/"):]
                pods.append(pod)
        except Exception:
            continue

    # De-dup while preserving order.
    seen: set[str] = set()
    return [p for p in pods if not (p in seen or seen.add(p))]


def force_delete_pods(
    ui: Ui, ex: Executor, plan: HelmTarget, pods: list[str],
):
    """Delete the specified pods immediately (best-effort).

    Uses ``kubectl delete pods --grace-period=0 --force``.

    *pods* should be the list captured by :func:`snapshot_release_pods`
    **before** ``helm upgrade`` was run, so only pre-upgrade pods are
    targeted and newly created pods are never touched.
    """
    if not pods:
        ui.warning("No pre-upgrade pods to delete. Skipping pod deletion.")
        return

    ui.log(f"Deleting {len(pods)} pre-upgrade pod(s) immediately (best-effort).")
    cmd = kube_cmd(
        plan, "kubectl", "-n", plan.namespace,
        "delete", "pods", *pods,
        "--grace-period=0", "--force",
    )
    run(ex, cmd)  # best-effort


def wait_for_rollout(ui: Ui, ex: Executor, plan: HelmTarget):
    """Wait for all workloads in the release to finish their rollout.

    Uses ``kubectl rollout status`` for each Deployment, StatefulSet, or
    DaemonSet found in the Helm manifest.  On timeout or failure, prints
    diagnostic information (pod status and warning events) before raising.
    """
    workloads = _get_release_workloads(ui, ex, plan)
    if not workloads:
        ui.warning("No workloads found in the release manifest. Skipping rollout wait.")
        return

    for kind, name in workloads:
        ui.log(f"Waiting for {kind}/{name} rollout to complete")
        timeout = plan.timeout
        # kubectl expects a duration with units (e.g. 2s, 10m). Make "2" mean "2s".
        if timeout.isdigit():
            timeout = f"{timeout}s"
        cmd = kube_cmd(
            plan, "kubectl", "-n", plan.namespace,
            "rollout", "status", f"{kind}/{name}",
            "--timeout", timeout,
        )
        rc, out, err = run(ex, cmd)
        if rc != 0:
            ui.warning(
                f"Rollout failed or timed out for {kind}/{name}. "
                "Fetching diagnostic information."
            )
            _show_diagnostics(ui, ex, plan)
            raise ExecutorError(
                f"Rollout did not complete for {kind}/{name}. "
                f"{(err or out).strip()}"
            )
