"""Kubernetes Job-based helm operation runner.

Offloads ``helm upgrade`` to K8s Jobs so the operation survives API pod
restarts. Provides recovery on startup by scanning for existing Jobs.
"""

from __future__ import annotations

import logging
import os
import shlex
from datetime import UTC, datetime
from typing import Any

from fastapi import HTTPException
from kubernetes.client import (
    V1Container,
    V1EnvVar,
    V1Job,
    V1JobSpec,
    V1ObjectMeta,
    V1PodSpec,
    V1PodTemplateSpec,
)

from ilum.api.models import OperationLog, OperationResponse
from ilum.api.operations import OperationStore
from ilum.constants import CHART_REPO_URL, HELM_TIMEOUT, is_local_chart
from ilum.core.kubernetes import KubeClient

logger = logging.getLogger(__name__)

MANAGED_BY_LABEL = "ilum-api"
LABEL_MANAGED_BY = "app.kubernetes.io/managed-by"
LABEL_OPERATION = "ilum.cloud/operation"
LABEL_OPERATION_ID = "ilum.cloud/operation-id"
LABEL_RELEASE = "ilum.cloud/release"
ANNOTATION_MODULES = "ilum.cloud/modules"
ANNOTATION_CHART_REF = "ilum.cloud/chart-ref"

JOB_ACTIVE_DEADLINE_SECONDS = 900  # 15 min (1.5x the default 10m Helm timeout)
JOB_TTL_SECONDS_AFTER_FINISHED = 86400  # 24 hours


def _get_api_image() -> str:
    """Return the container image for helm Jobs.

    Falls back to ``ilum/ilum-api:latest`` if ``ILUM_API_IMAGE`` is unset.
    """
    return os.environ.get("ILUM_API_IMAGE", "ilum/ilum-api:latest")


def _get_service_account() -> str:
    return os.environ.get("ILUM_SERVICE_ACCOUNT", "")


def _get_api_image_set_flags() -> list[str]:
    """Generate ``--set`` flags to pin the API image through helm upgrades.

    Prevents ``helm upgrade --reuse-values`` from reverting the API
    deployment to a stale image tag stored in the release values.
    """
    api_image = _get_api_image()
    if api_image == "ilum/ilum-api:latest":
        return []  # Default fallback — nothing to pin
    if ":" in api_image:
        repo, tag = api_image.rsplit(":", 1)
        return [
            f"ilum-api.image.repository={repo}",
            f"ilum-api.image.tag={tag}",
        ]
    return []


def build_helm_job(
    op_id: str,
    operation: str,
    release: str,
    namespace: str,
    chart_ref: str,
    set_flags: list[str],
    *,
    modules: list[str] | None = None,
    version: str = "",
    reset_defaults: bool = False,
    atomic: bool = False,
) -> V1Job:
    """Construct a :class:`V1Job` that executes a ``helm upgrade``."""
    job_name = f"ilum-helm-{op_id}"

    labels = {
        LABEL_MANAGED_BY: MANAGED_BY_LABEL,
        LABEL_OPERATION: operation,
        LABEL_OPERATION_ID: op_id,
        LABEL_RELEASE: release,
    }
    annotations: dict[str, str] = {
        ANNOTATION_CHART_REF: chart_ref,
    }
    if modules:
        annotations[ANNOTATION_MODULES] = ",".join(modules)

    # Build the shell script
    script_parts: list[str] = ["set -euo pipefail"]

    if not is_local_chart(chart_ref):
        repo_url = os.environ.get("ILUM_HELM_REPO_URL", CHART_REPO_URL)
        script_parts.append(f"helm repo add ilum {shlex.quote(repo_url)} --force-update")
        script_parts.append("helm repo update ilum")

    cmd = [
        "helm",
        "upgrade",
        shlex.quote(release),
        shlex.quote(chart_ref),
        "--namespace",
        shlex.quote(namespace),
        "--timeout",
        HELM_TIMEOUT,
    ]

    if atomic:
        cmd.append("--atomic")

    if reset_defaults:
        cmd.append("--reset-then-reuse-values")
    else:
        cmd.append("--reuse-values")

    if version:
        cmd.extend(["--version", shlex.quote(version)])

    # Pin the current API image so the upgrade doesn't revert it
    for flag in _get_api_image_set_flags():
        cmd.extend(["--set", shlex.quote(flag)])

    for flag in set_flags:
        cmd.extend(["--set", shlex.quote(flag)])

    script_parts.append(" ".join(cmd))
    shell_script = "\n".join(script_parts)

    container = V1Container(
        name="helm",
        image=_get_api_image(),
        command=["/bin/sh", "-c", shell_script],
        env=[
            V1EnvVar(name="HOME", value="/tmp"),
        ],
    )

    sa_name = _get_service_account()

    pod_spec = V1PodSpec(
        containers=[container],
        restart_policy="Never",
        service_account_name=sa_name or None,
    )

    template = V1PodTemplateSpec(
        metadata=V1ObjectMeta(labels=labels),
        spec=pod_spec,
    )

    job_spec = V1JobSpec(
        template=template,
        backoff_limit=0,
        active_deadline_seconds=JOB_ACTIVE_DEADLINE_SECONDS,
        ttl_seconds_after_finished=JOB_TTL_SECONDS_AFTER_FINISHED,
    )

    return V1Job(
        api_version="batch/v1",
        kind="Job",
        metadata=V1ObjectMeta(
            name=job_name,
            labels=labels,
            annotations=annotations,
        ),
        spec=job_spec,
    )


def get_job_status(job: Any) -> tuple[str, str]:
    """Map a K8s Job object to ``(status, error_message)``.

    Returns one of ``"completed"``, ``"failed"``, ``"running"``, ``"pending"``.
    """
    conditions = job.status.conditions or []
    for cond in conditions:
        if cond.type == "Complete" and cond.status == "True":
            return "completed", ""
        if cond.type == "Failed" and cond.status == "True":
            reason = cond.reason or ""
            message = cond.message or ""
            return "failed", f"{reason}: {message}".strip(": ")

    # Fallback: authoritative counters (handles edge cases where conditions
    # are empty/None but the Job has actually finished)
    if job.status.succeeded and job.status.succeeded > 0:
        return "completed", ""
    if job.status.failed and job.status.failed > 0:
        return "failed", "Job failed"

    if job.status.active and job.status.active > 0:
        return "running", ""

    return "pending", ""


def _strip_helm_notes(text: str) -> str:
    """Remove the NOTES.txt section that Helm prints after upgrade."""
    for marker in ("NOTES:\n", "NOTES:\r\n"):
        idx = text.find(marker)
        if idx != -1:
            return text[:idx].rstrip()
    return text


def _capture_job_logs(
    op: OperationResponse,
    k8s: KubeClient,
    namespace: str,
    *,
    tail_lines: int = 200,
    max_chars: int = 4000,
) -> None:
    """Best-effort capture of logs from a Job's pod into the operation.

    Only overwrites ``op.logs`` when new log text is available — never
    replaces existing logs with nothing.  Strips Helm NOTES.txt output.
    """
    try:
        log_text = k8s.get_job_pod_logs(op.job_name, namespace, tail_lines=tail_lines)
        if log_text:
            log_text = _strip_helm_notes(log_text)
        if log_text:
            op.logs = [
                OperationLog(
                    timestamp=datetime.now(UTC).isoformat(),
                    message=log_text[-max_chars:] if len(log_text) > max_chars else log_text,
                    level="error" if op.status == "failed" else "info",
                )
            ]
    except Exception:
        pass  # Keep existing logs — never overwrite with nothing


def refresh_operation_from_job(
    op: OperationResponse,
    k8s: KubeClient,
    namespace: str,
) -> None:
    """Refresh an operation's status from its backing K8s Job.

    When the Job transitions to a terminal state (completed/failed), logs
    are captured immediately — this is the most reliable time since the
    pod is most likely still around.  Once terminal, logs are frozen and
    not re-fetched on subsequent polls.

    If the Job no longer exists, marks the operation as failed.
    """
    if not op.job_name or op.status not in ("pending", "running", "awaiting_readiness"):
        return

    # For awaiting_readiness: only check Job existence/failure, not status transitions
    if op.status == "awaiting_readiness":
        try:
            job = k8s.get_job(op.job_name, namespace)
            status, error = get_job_status(job)
            if status == "failed":
                op.status = "failed"
                op.error = error or "Backing K8s Job failed"
                op.completed_at = datetime.now(UTC).isoformat()
                _capture_job_logs(op, k8s, namespace)
            # If Job still exists and is completed, no change — readiness check handles it
        except Exception:
            op.status = "failed"
            op.error = "Operation was cancelled or superseded"
            op.completed_at = datetime.now(UTC).isoformat()
        return

    try:
        job = k8s.get_job(op.job_name, namespace)
        status, error = get_job_status(job)
        if error:
            op.error = error
        if status == "completed":
            # Helm upgrade done — transition to readiness check phase
            op.status = "awaiting_readiness"
            op.progress = 80
            op.helm_completed_at = datetime.now(UTC).isoformat()
            _capture_job_logs(op, k8s, namespace)
            return
        if status == "failed":
            op.status = "failed"
            op.completed_at = datetime.now(UTC).isoformat()
            _capture_job_logs(op, k8s, namespace)
            return
        if status != op.status:
            op.status = status
    except Exception:
        # Job doesn't exist or API error -> mark failed
        op.status = "failed"
        op.error = "Backing K8s Job no longer exists or is inaccessible"
        op.completed_at = datetime.now(UTC).isoformat()


def recover_operations(
    k8s: KubeClient,
    store: OperationStore,
    namespace: str,
) -> int:
    """Scan for existing helm Jobs and import them into the store.

    Called once on startup. Returns the number of recovered operations.
    """
    selector = f"{LABEL_MANAGED_BY}={MANAGED_BY_LABEL}"
    try:
        jobs = k8s.list_jobs_by_label(namespace, selector)
    except Exception:
        logger.exception("Failed to list Jobs for recovery")
        return 0

    count = 0
    for job in jobs:
        op_id = (job.metadata.labels or {}).get(LABEL_OPERATION_ID, "")
        if not op_id:
            continue

        # Skip if already in store
        if store.get(op_id) is not None:
            continue

        operation = (job.metadata.labels or {}).get(LABEL_OPERATION, "upgrade")
        modules_csv = (job.metadata.annotations or {}).get(ANNOTATION_MODULES, "")
        modules = [m for m in modules_csv.split(",") if m] if modules_csv else []

        status, error = get_job_status(job)

        created_at = ""
        if job.metadata.creation_timestamp:
            created_at = job.metadata.creation_timestamp.isoformat()

        # Completed Jobs enter readiness check; first poll will promote
        if status == "completed":
            status_for_op = "awaiting_readiness"
            progress = 80
            # Use the Job's actual completion time, not now()
            completion_time = getattr(job.status, "completion_time", None)
            helm_completed = (
                completion_time.isoformat() if completion_time else datetime.now(UTC).isoformat()
            )
            completed_at = ""
        elif status == "failed":
            status_for_op = status
            progress = 0
            helm_completed = ""
            completed_at = datetime.now(UTC).isoformat()
        else:
            status_for_op = status
            progress = 0
            helm_completed = ""
            completed_at = ""

        op = OperationResponse(
            id=op_id,
            status=status_for_op,
            operation=operation,
            modules=modules,
            progress=progress,
            created_at=created_at,
            completed_at=completed_at,
            error=error,
            job_name=job.metadata.name,
            helm_completed_at=helm_completed,
        )
        # Capture logs with generous limits (recovery may be our only chance)
        _capture_job_logs(op, k8s, namespace)
        store.import_operation(op)
        count += 1
        logger.info(
            "Recovered operation %s (status=%s) from Job %s", op_id, status, job.metadata.name
        )

    return count


def find_active_helm_job(k8s: KubeClient, namespace: str) -> str | None:
    """Return the name of any non-terminal ilum-api Job, or ``None``."""
    selector = f"{LABEL_MANAGED_BY}={MANAGED_BY_LABEL}"
    try:
        jobs = k8s.list_jobs_by_label(namespace, selector)
    except Exception:
        return None

    for job in jobs:
        status, _ = get_job_status(job)
        if status in ("pending", "running"):
            return str(job.metadata.name)
    return None


def has_any_helm_job(k8s: KubeClient, namespace: str) -> bool:
    """Return ``True`` if any ilum-api managed Job exists (any status).

    Unlike :func:`find_active_helm_job`, this does **not** swallow
    exceptions — callers should handle K8s API errors.  Used by the
    startup auto-recovery guard where false negatives are dangerous
    (a wrongly-returned ``False`` would cause an unwanted rollback).
    """
    selector = f"{LABEL_MANAGED_BY}={MANAGED_BY_LABEL}"
    jobs = k8s.list_jobs_by_label(namespace, selector)
    return len(jobs) > 0


def check_no_active_helm_operation(
    store: OperationStore,
    k8s: KubeClient,
    namespace: str,
) -> None:
    """Raise ``HTTPException(409)`` if a helm operation is already active.

    Reconciles in-memory state from K8s Jobs before checking, so that
    operations whose backing Jobs have completed/failed are not treated
    as active.
    """
    # Reconcile: refresh active operations from K8s
    for op in store.list_all():
        if op.status in ("pending", "running", "awaiting_readiness") and op.job_name:
            refresh_operation_from_job(op, k8s, namespace)

    if store.has_active_helm_operation():
        raise HTTPException(
            status_code=409,
            detail="Another helm operation is already in progress. Wait for it to complete.",
        )

    active_job = find_active_helm_job(k8s, namespace)
    if active_job:
        raise HTTPException(
            status_code=409,
            detail=f"A helm Job '{active_job}' is still running. Wait for it to complete.",
        )
