"""Node lifecycle operator for Citadel.

Kopf-based operator that reconciles NodeTerminationPolicy CRDs using
Citadel's NodeManager (drain, label, cordon) and HealthChecker (4-state
health model). Ported from Apollo's spoke/node_lifecycle/ — Citadel now
owns the operator since the CRDs and node primitives are Citadel's.

Run standalone:
    kopf run --standalone -m citadel_operator.k3s.lifecycle_operator
"""

import logging
import re
from datetime import UTC, datetime
from typing import Any

import kopf
from kubernetes import client
from kubernetes import config as k8s_config
from kubernetes.client.exceptions import ApiException

from citadel_operator.k3s.health import HealthChecker, HealthState
from citadel_operator.k3s.lifecycle_models import (
    CycleResult,
    LifecycleConfig,
    NodeTerminationPolicy,
    PolicyMatch,
)
from citadel_operator.k3s.node import (
    LABEL_TERMINATE_REASON,
    NodeInfo,
    NodeManager,
)

logger = logging.getLogger(__name__)

# Operator metadata
OPERATOR_NAME = "citadel-lifecycle"
OPERATOR_VERSION = "0.2.0"
CRD_GROUP = "citadel.dev"
CRD_VERSION = "v1alpha1"
CRD_PLURAL = "nodeterminationpolicies"


class LifecyclePolicyEvaluator:
    """Evaluates termination policies against nodes.

    Uses Citadel's NodeManager for node state and HealthChecker for
    health-based assessment. This is a pure evaluation engine with no
    side effects — returns matches for the controller to act on.
    """

    def __init__(self, health_checker: HealthChecker | None = None) -> None:
        self._health_checker = health_checker or HealthChecker()

    def evaluate(
        self,
        policies: list[NodeTerminationPolicy],
        nodes: list[NodeInfo],
    ) -> list[PolicyMatch]:
        """Evaluate all enabled policies against all nodes.

        Policies are evaluated in priority order (highest first).
        Each node is matched by at most one policy (the highest priority).

        Args:
            policies: All NodeTerminationPolicy resources.
            nodes: All nodes from NodeManager.list_nodes().

        Returns:
            List of PolicyMatch results, one per matched node.
        """
        enabled = [p for p in policies if p.spec.enabled]
        enabled.sort(key=lambda p: p.spec.priority, reverse=True)

        matched_nodes: dict[str, PolicyMatch] = {}

        for policy in enabled:
            for node in nodes:
                if node.name in matched_nodes:
                    continue

                if node.marked_for_termination:
                    continue

                if self._node_matches_policy(node, policy):
                    matched_nodes[node.name] = PolicyMatch(
                        node_name=node.name,
                        policy_name=policy.name,
                        reason=policy.spec.reason,
                        priority=policy.spec.priority,
                        age_hours=node.age_hours,
                        dry_run=policy.spec.dry_run,
                        drain_timeout=policy.spec.drain.timeout_seconds,
                        delete_emptydir_data=policy.spec.drain.delete_emptydir_data,
                        force_after_timeout=policy.spec.drain.force_after_timeout,
                    )
                    logger.info(
                        "Policy %s matched node %s (age=%.1fh, reason=%s)",
                        policy.name,
                        node.name,
                        node.age_hours,
                        policy.spec.reason,
                    )

        return list(matched_nodes.values())

    def _node_matches_policy(
        self,
        node: NodeInfo,
        policy: NodeTerminationPolicy,
    ) -> bool:
        """Check if a single node matches a policy's selector."""
        selector = policy.spec.selector

        # Check node label selector
        if selector.node_selector:
            for key, value in selector.node_selector.items():
                if node.labels.get(key) != value:
                    return False

        # Check age-based selector
        if selector.max_age_hours is not None:
            if node.age_hours < selector.max_age_hours:
                return False

        # Check condition-based selectors (ALL conditions must match)
        if selector.conditions:
            if not self._conditions_match(node, selector.conditions):
                return False

        # At least one selector criterion must be present
        has_criteria = (
            selector.max_age_hours is not None
            or len(selector.conditions) > 0
            or len(selector.node_selector) > 0
        )
        return has_criteria

    @staticmethod
    def _conditions_match(
        node: NodeInfo,
        required: list,
    ) -> bool:
        """Check if a node's conditions match all required conditions."""
        for req in required:
            actual = node.conditions.get(req.type)
            if actual != req.status:
                return False
        return True


class LifecycleController:
    """Orchestrates node lifecycle management using Citadel primitives.

    Uses NodeManager for drain/label/cordon (richer semantics than Apollo's
    reimplementation) and HealthChecker for health-based assessment.
    """

    def __init__(self, config: LifecycleConfig | None = None) -> None:
        self._config = config or LifecycleConfig()

        # Initialize Kubernetes client
        if self._config.kubeconfig:
            k8s_config.load_kube_config(config_file=self._config.kubeconfig)
        else:
            try:
                k8s_config.load_incluster_config()
            except k8s_config.ConfigException:
                k8s_config.load_kube_config()

        self._node_manager = NodeManager(kubeconfig=self._config.kubeconfig)
        self._health_checker = HealthChecker(max_age_hours=self._config.max_age_hours)
        self._evaluator = LifecyclePolicyEvaluator(health_checker=self._health_checker)
        self._custom = client.CustomObjectsApi()

    async def run_evaluation_cycle(self) -> CycleResult:
        """Run a full policy evaluation cycle.

        Fetches all policies and nodes, evaluates matches,
        labels matched nodes, and drains them using NodeManager.

        Returns:
            CycleResult with summary of actions taken.
        """
        policies = self._list_policies()
        if not policies:
            logger.debug("No NodeTerminationPolicies found")
            return CycleResult(
                policies_evaluated=0, nodes_checked=0,
                nodes_matched=0, nodes_drained=0,
            )

        # Use NodeManager for node state (not raw V1Node)
        nodes = self._node_manager.list_nodes()

        matches = self._evaluator.evaluate(policies, nodes)

        logger.info(
            "Evaluation cycle: %d policies, %d nodes, %d matches",
            len(policies), len(nodes), len(matches),
        )

        nodes_drained = 0
        nodes_failed: list[str] = []
        dry_run_only: list[str] = []

        for match in matches:
            # Use NodeManager.label_for_termination (not reimplemented patching)
            self._label_node_for_termination(match)

            self._update_policy_status(match.policy_name, matched=True)

            if match.dry_run or self._config.dry_run_override:
                logger.info(
                    "[DRY RUN] Would drain node %s (policy=%s, reason=%s)",
                    match.node_name, match.policy_name, match.reason,
                )
                dry_run_only.append(match.node_name)
                continue

            # Use NodeManager.drain (richer: force_after_timeout, emptyDir filtering)
            result = self._node_manager.drain(
                name=match.node_name,
                timeout_seconds=match.drain_timeout,
                delete_emptydir_data=match.delete_emptydir_data,
                force_after_timeout=match.force_after_timeout,
            )
            if result.success:
                nodes_drained += 1
                self._update_policy_status(match.policy_name, terminated=True)
                logger.info(
                    "Drained node %s (%d pods evicted, policy=%s)",
                    match.node_name, result.pods_evicted, match.policy_name,
                )
            else:
                nodes_failed.append(match.node_name)
                logger.warning(
                    "Failed to drain node %s: %s",
                    match.node_name, result.error,
                )

        return CycleResult(
            policies_evaluated=len(policies),
            nodes_checked=len(nodes),
            nodes_matched=len(matches),
            nodes_drained=nodes_drained,
            nodes_failed=nodes_failed,
            dry_run_only=dry_run_only,
        )

    def _list_policies(self) -> list[NodeTerminationPolicy]:
        """Fetch all NodeTerminationPolicy CRDs."""
        try:
            result = self._custom.list_cluster_custom_object(
                group=CRD_GROUP,
                version=CRD_VERSION,
                plural=CRD_PLURAL,
            )
            return [
                NodeTerminationPolicy.from_dict(item)
                for item in result.get("items", [])
            ]
        except ApiException as e:
            if e.status == 404:
                logger.warning(
                    "NodeTerminationPolicy CRD not found. "
                    "Run 'citadel lifecycle install' to set up CRDs."
                )
                return []
            raise

    @staticmethod
    def _sanitize_label_value(value: str) -> str:
        """Sanitize a string for use as a Kubernetes label value."""
        sanitized = re.sub(r"[^a-zA-Z0-9._-]", "-", value)
        sanitized = sanitized.strip("-._")
        return sanitized[:63]

    def _label_node_for_termination(self, match: PolicyMatch) -> None:
        """Label a node with termination intent using NodeManager."""
        try:
            self._node_manager.label_for_termination(
                name=match.node_name,
                reason=self._sanitize_label_value(match.reason),
                policy_name=self._sanitize_label_value(match.policy_name),
            )
        except ApiException as e:
            logger.error(
                "Failed to label node %s: %s", match.node_name, e.reason
            )

    def _update_policy_status(
        self, policy_name: str, matched: bool = False, terminated: bool = False
    ) -> None:
        """Update a NodeTerminationPolicy's status subresource."""
        try:
            current = self._custom.get_cluster_custom_object(
                group=CRD_GROUP,
                version=CRD_VERSION,
                plural=CRD_PLURAL,
                name=policy_name,
            )
            status = current.get("status", {})

            now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
            patch: dict[str, Any] = {"lastEvaluated": now}

            if matched:
                patch["nodesMatched"] = status.get("nodesMatched", 0) + 1

            if terminated:
                patch["nodesTerminated"] = status.get("nodesTerminated", 0) + 1
                patch["lastTermination"] = now

            self._custom.patch_cluster_custom_object_status(
                group=CRD_GROUP,
                version=CRD_VERSION,
                plural=CRD_PLURAL,
                name=policy_name,
                body={"status": patch},
            )
        except ApiException as e:
            logger.warning(
                "Failed to update policy status for %s: %s",
                policy_name, e.reason,
            )


# === Global controller instance ===

_controller: LifecycleController | None = None


def get_controller() -> LifecycleController:
    """Get or create the global lifecycle controller."""
    global _controller
    if _controller is None:
        _controller = LifecycleController()
    return _controller


# === Kopf Event Handlers ===


@kopf.on.create(CRD_GROUP, CRD_VERSION, CRD_PLURAL)
async def on_policy_create(
    body: dict[str, Any],
    name: str,
    **kwargs,
) -> str | None:
    """Handle new NodeTerminationPolicy creation."""
    logger.info("NodeTerminationPolicy %s created, triggering evaluation", name)
    controller = get_controller()
    result = await controller.run_evaluation_cycle()
    logger.info(
        "Evaluation after policy create: %d matched, %d drained",
        result.nodes_matched, result.nodes_drained,
    )
    return None


@kopf.on.update(CRD_GROUP, CRD_VERSION, CRD_PLURAL)
async def on_policy_update(
    body: dict[str, Any],
    name: str,
    **kwargs,
) -> str | None:
    """Handle NodeTerminationPolicy spec changes."""
    logger.info("NodeTerminationPolicy %s updated, triggering evaluation", name)
    controller = get_controller()
    result = await controller.run_evaluation_cycle()
    logger.info(
        "Evaluation after policy update: %d matched, %d drained",
        result.nodes_matched, result.nodes_drained,
    )
    return None


@kopf.on.delete(CRD_GROUP, CRD_VERSION, CRD_PLURAL)
async def on_policy_delete(
    name: str,
    **kwargs,
) -> None:
    """Handle NodeTerminationPolicy deletion."""
    logger.info(
        "NodeTerminationPolicy %s deleted. "
        "Nodes already labeled by this policy will still be processed.",
        name,
    )


@kopf.timer(
    CRD_GROUP,
    CRD_VERSION,
    CRD_PLURAL,
    interval=60,
    initial_delay=10,
)
async def on_evaluation_timer(
    body: dict[str, Any],
    name: str,
    **kwargs,
) -> None:
    """Periodic evaluation timer.

    Deduped: only first policy alphabetically runs evaluation to avoid
    N evaluations per cycle when N policies exist.
    """
    controller = get_controller()
    policies = controller._list_policies()
    if not policies:
        return

    first_policy = sorted(policies, key=lambda p: p.name)[0]
    if name != first_policy.name:
        return

    result = await controller.run_evaluation_cycle()
    if result.nodes_matched > 0:
        logger.info(
            "Timer evaluation: %d/%d nodes matched, %d drained, %d dry-run",
            result.nodes_matched,
            result.nodes_checked,
            result.nodes_drained,
            len(result.dry_run_only),
        )


@kopf.on.startup()
async def on_startup(**kwargs) -> None:
    """Initialize the operator on startup."""
    logger.info("Starting Citadel Node Lifecycle Operator %s", OPERATOR_VERSION)

    try:
        k8s_config.load_incluster_config()
        logger.info("Loaded in-cluster Kubernetes configuration")
    except k8s_config.ConfigException:
        k8s_config.load_kube_config()
        logger.info("Loaded kubeconfig from local machine")

    get_controller()
    logger.info("Node Lifecycle Operator ready")


@kopf.on.cleanup()
async def on_cleanup(**kwargs) -> None:
    """Cleanup on operator shutdown."""
    logger.info("Node Lifecycle Operator shutting down")


@kopf.on.probe(id="health")
async def health_probe(**kwargs) -> dict[str, Any]:
    """Health probe for liveness/readiness checks."""
    return {
        "operator": OPERATOR_NAME,
        "version": OPERATOR_VERSION,
        "timestamp": datetime.now(UTC).isoformat(),
    }
