# clab_connector/services/eda_integrator.py

import json
import logging
import os
import re
import ssl
import time
from pathlib import Path

from eda_integration.clients.eda.client import EDAClient
from eda_integration.clients.eda.saas_client import EDASaasClient
from config import get_software_version, get_srl_yang_schema_build
from eda_integration.utils import helpers
from eda_integration.utils.constants import (
    EDA_SAAS_ORGANIZATIONS_PATH,
    EDA_SAAS_POOLS_PATH,
    EDA_SAAS_SUBNETS_PATH,
    HTTP_CREATED,
    HTTP_NO_CONTENT,
    HTTP_OK,
    SUBSTEP_INDENT,
)
from eda_integration.utils.api_utils import extract_k8s_names, parse_organizations_response
from eda_integration.utils.exceptions import ClabIntegratorError, EDAConnectionError
from state import get_topology_data_path
from utils.json_handler import read_resources

logger = logging.getLogger(__name__)

# Batch settings for toponode transactions (matches old create_toponodes behavior)
TOPONODE_BATCH_SIZE = 3
TOPONODE_BATCH_DELAY_SEC = 2
# Refetch auth token every N nodes to avoid expiry (API token expires after ~10 toponodes)
TOPONODE_TOKEN_REFETCH_INTERVAL = 5


def _decode_eda_response(data: bytes, context: str = "EDA API") -> dict | list:
    """Decode UTF-8 bytes and parse JSON; raise ClabIntegratorError with clear message on failure."""
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError as e:
        raise ClabIntegratorError(f"{context} response was not valid UTF-8: {e}") from e
    try:
        return json.loads(text)
    except json.JSONDecodeError as e:
        raise ClabIntegratorError(f"Invalid JSON from {context}: {e}") from e


def _subnet_ids_from_response(subnets_resp: dict | list) -> list[str]:
    """Extract subnet id(s) from subnets API response (single object or list)."""
    if isinstance(subnets_resp, dict) and subnets_resp.get("id"):
        return [str(subnets_resp["id"])]
    if isinstance(subnets_resp, list):
        return [str(s["id"]) for s in subnets_resp if isinstance(s, dict) and s.get("id")]
    return []


class EDAIntegrator:
    """
    Single integrator for all EDA resources: namespace, init, node users/profiles,
    index pools, IP pools, fabric, vnet, toponodes, interfaces, topolinks.
    Uses eda_client transaction API for all resource creation.
    """

    def __init__(
        self,
        eda_client: EDAClient,
        cluster_model: str = "dedicated",
        eda_saas_client: EDASaasClient | None = None,
    ):
        self.eda_client = eda_client
        self.cluster_model = cluster_model
        self.eda_saas_client = eda_saas_client

    def get_namespaces(self) -> dict[str, str]:
        """
        GET organizations from EDA SaaS API; return mapping of namespace name -> id.
        Uses EDASaasClient when present (shared/MSP), otherwise falls back to core EDA client.
        """
        if self.eda_saas_client is not None:
            return self.eda_saas_client.get_organizations()
        resp = self.eda_client.get(EDA_SAAS_ORGANIZATIONS_PATH)
        if resp.status != HTTP_OK:
            raise ClabIntegratorError(
                f"Failed to get organizations: {resp.status} {resp.data.decode(errors='replace')}"
            )
        data = _decode_eda_response(resp.data, "organizations API")
        if not isinstance(data, list):
            raise ClabIntegratorError("Organizations response was not a list")
        return parse_organizations_response(data)

    def run_create_shared_mgmt_resources(self, org_id: str, subnet: str) -> None:
        """
        POST to IP management subnets API with org scope headers (shared/MSP mgmt resources),
        then for each created subnet POST to ipmanagement/pools with name "clab-int-subnet".
        Uses subnet from user (e.g. 10.150.150.0/24). On error, logs and returns without raising.
        """
        payload = {"subnetIps": [subnet.strip()]}
        try:
            if self.eda_saas_client is not None:
                subnets_resp = self.eda_saas_client.post_subnets(org_id, payload)
            else:
                url = f"{self.eda_client.url}/{EDA_SAAS_SUBNETS_PATH}"
                headers = {
                    **self.eda_client.get_headers(),
                    "Content-Type": "application/json",
                    "X-Scope": str(org_id),
                    "X-Organization": str(org_id),
                }
                body = json.dumps(payload).encode("utf-8")
                try:
                    resp = self.eda_client.http.request("POST", url, headers=headers, body=body)
                except ssl.SSLError as e:
                    raise EDAConnectionError(f"SSL error connecting to EDA: {e}") from e
                except OSError as e:
                    raise EDAConnectionError(f"Network error connecting to EDA: {e}") from e
                if resp.status not in (HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT):
                    raise ClabIntegratorError(
                        f"Failed to create shared mgmt resources: {resp.status} {resp.data.decode(errors='replace')}"
                    )
                subnets_resp = (
                    []
                    if resp.status == HTTP_NO_CONTENT or not resp.data
                    else _decode_eda_response(resp.data, "subnets API")
                )
            for subnet_id in _subnet_ids_from_response(subnets_resp):
                pool_payload = {
                    "allocations": [],
                    "name": "clab-int-subnet",
                    "reservations": [],
                    "subnetId": subnet_id,
                }
                if self.eda_saas_client is not None:
                    self.eda_saas_client.post_pool(org_id, pool_payload)
                else:
                    pool_url = f"{self.eda_client.url}/{EDA_SAAS_POOLS_PATH}"
                    pool_headers = {
                        **self.eda_client.get_headers(),
                        "Content-Type": "application/json",
                        "X-Scope": str(org_id),
                        "X-Organization": str(org_id),
                    }
                    pool_body = json.dumps(pool_payload).encode("utf-8")
                    try:
                        pool_resp = self.eda_client.http.request(
                            "POST", pool_url, headers=pool_headers, body=pool_body
                        )
                    except ssl.SSLError as e:
                        raise EDAConnectionError(f"SSL error connecting to EDA: {e}") from e
                    except OSError as e:
                        raise EDAConnectionError(f"Network error connecting to EDA: {e}") from e
                    if pool_resp.status not in (HTTP_OK, HTTP_CREATED, HTTP_NO_CONTENT):
                        raise ClabIntegratorError(
                            f"Failed to create pool for subnet {subnet_id}: {pool_resp.status} {pool_resp.data.decode(errors='replace')}"
                        )
            logger.info("Shared mgmt resources (subnets and pools) created for org %s", org_id)
        except Exception as e:
            logger.warning("Skipping MSP subnet creation: %s", e)
            print("Skipping MSP subnet creation...")

    def run(
        self,
        namespace: str,
        stage_dir: str | Path,
    ) -> None:
        """
        Run prechecks, then create EDA resources via transactions.

        stage_dir: root stage directory (e.g. from paths.STAGE_DIR). Topology, resources,
          and toponode paths are derived as stage_dir/clab/<clab-topology*>/topology-data.json,
          stage_dir/resources, stage_dir/resources/toponode.
        """
        stage = Path(stage_dir).resolve()
        _topology_file = get_topology_data_path(stage).resolve()
        _resources_path = (stage / "resources").resolve()
        _toponode_dir = (_resources_path / "toponode").resolve()

        logger.info("== Running pre-checks ==")
        self.prechecks()
        self.namespace_prechecks(namespace)

        if not _topology_file.exists():
            raise ClabIntegratorError(
                f"Topology file not found: {_topology_file}. Run containerlab inspect first."
            )
        if not _resources_path.is_dir():
            raise ClabIntegratorError(
                f"Resources directory not found: {_resources_path}"
            )
        if not _toponode_dir.is_dir():
            raise ClabIntegratorError(
                f"Toponode directory not found: {_toponode_dir}"
            )

        logger.info("== Creating namespace, init, node users, groups, and profiles ==")

        self.create_namespace(namespace)
        self._commit_transaction("create namespace")

        logger.info("== Creating init ==")
        self.create_init(namespace)
        self._commit_transaction("create init (bootstrap)")

        logger.info("== Creating node users, groups, and profiles ==")
        self.create_node_user_groups(namespace)
        self.create_node_users(namespace, ssh_pub_keys=[])
        self._commit_transaction("create node users and groups")

        self.create_node_profiles(namespace)
        self._commit_transaction("create node profiles")

        logger.info("== Creating index pools, IP pools, fabric, and vnet ==")
        self.create_index_pools(namespace, _resources_path)
        self._commit_transaction("create index pools")

        self.create_ip_pools(namespace)
        self._commit_transaction("create IP pools")

        self.create_fabric(namespace, _resources_path)
        self._commit_transaction("create fabric")
        self.create_vnet(namespace, _resources_path)
        self._commit_transaction("create vnet")

        logger.info("== Posting resources from %s ==", _resources_path)
        self.post_resources_from_directory(_resources_path, namespace)

        logger.info("Done!")

    def prechecks(self) -> None:
        if not self.eda_client.is_up():
            raise EDAConnectionError("EDA not up or unreachable")
        if not self.eda_client.is_authenticated():
            raise EDAConnectionError("EDA credentials invalid")

    def namespace_prechecks(self, namespace: str) -> None:
        resp = self.eda_client.get('apps/core.eda.nokia.com/v1/namespaces')
        if resp.status != HTTP_OK:
            raise ClabIntegratorError(
                f"Failed to list namespaces: {resp.status} {resp.data.decode(errors='replace')}"
            )
        data = _decode_eda_response(resp.data, "namespaces API")
        existing_names = extract_k8s_names(data)
        if namespace in existing_names:
            logger.error(f"Namespace {namespace} already exists")
            raise ClabIntegratorError(f"Namespace {namespace} already exists. Aborting resource creation.")

    def _commit_transaction(self, description: str) -> Exception | None:
        try:
            self.eda_client.commit_transaction(description)
        except EDAConnectionError:
            logger.error("Failed to commit transaction (EDA connection error)")
            raise
        except Exception as e:
            logger.error(f"Failed to commit transaction: {e}")
            raise ClabIntegratorError(f"Unexpected error while committing transaction: {e}") from e

    def _clear_transaction(self) -> None:
        if hasattr(self.eda_client, "transactions"):
            self.eda_client.transactions = []

    def create_namespace(self, namespace: str) -> None:
        """Create a Namespace resource to bootstrap additional resources."""
        data = {"namespace": namespace}
        yml = helpers.render_template("management/namespace.j2", data)
        self.eda_client.add_create_to_transaction(yml)

    def create_init(self, namespace: str) -> None:
        """Create an Init resource in the namespace to bootstrap additional resources."""
        data = {
            "name": "init-base",
            "namespace": namespace,
            "nodeselectors": ["eda.nokia.com/security-profile=managed"],
        }
        yml = helpers.render_template("management/init.yaml.j2", data)
        item = self.eda_client.add_replace_to_transaction(yml)
        if not self.eda_client.is_transaction_item_valid(item):
            raise ClabIntegratorError("Validation error for init resource")

    def create_node_user_groups(self, namespace: str) -> None:
        """Create a NodeGroup resource for user groups (e.g. sudo)."""
        data = {"namespace": namespace}
        node_user_group = helpers.render_template(
            "management/node-user-group.yaml.j2", data
        )
        item = self.eda_client.add_replace_to_transaction(node_user_group)
        if not self.eda_client.is_transaction_item_valid(item):
            raise ClabIntegratorError("Validation error for node user group")

    def create_node_users(
        self, namespace: str, ssh_pub_keys: list[str] | None = None
    ) -> None:
        """Create NodeUser resources for SRL and SROS (ssh_pub_keys can be empty)."""
        ssh_pub_keys = ssh_pub_keys or []
        if not ssh_pub_keys:
            logger.warning(
                f"{SUBSTEP_INDENT}No SSH public keys provided. Proceeding with empty key list."
            )
        srl_data = {
            "namespace": namespace,
            "node_user": "admin",
            "username": "admin",
            "password": "NokiaSrl1!",
            "ssh_pub_keys": ssh_pub_keys,
            "node_selector": "eda.nokia.com/security-profile=managed",
        }
        srl_node_user = helpers.render_template("management/node-user.j2", srl_data)
        item_srl = self.eda_client.add_replace_to_transaction(srl_node_user)
        if not self.eda_client.is_transaction_item_valid(item_srl):
            raise ClabIntegratorError("Validation error for SRL node user")
        sros_data = {
            "namespace": namespace,
            "node_user": "admin-sros",
            "username": "admin",
            "password": "NokiaSros1!",
            "ssh_pub_keys": ssh_pub_keys,
            "node_selector": "containerlab=managedSros",
        }
        sros_node_user = helpers.render_template(
            "management/node-user.j2", sros_data
        )
        item_sros = self.eda_client.add_replace_to_transaction(sros_node_user)
        if not self.eda_client.is_transaction_item_valid(item_sros):
            raise ClabIntegratorError("Validation error for SROS node user")

    def create_node_profiles(self, namespace: str) -> None:
        """Create default NodeProfile resources for SRL and SROS."""
        sw_version = get_software_version()
        version_escaped = re.escape(sw_version)
        srl_data = {
            "namespace": namespace,
            "profile_name": f"node-profile-srlinux-{sw_version}",
            "sw_version": sw_version,
            "gnmi_port": "57410",
            "operating_system": "srl",
            "version_path": ".system.information.version",
            "version_match": rf"v{version_escaped}.*",
            "yang_path": f"https://eda-asvr.eda-system.svc/eda-system/schemaprofiles/srlinux-ghcr-{sw_version}/srlinux-{sw_version}-{get_srl_yang_schema_build()}.zip",
            "annotate": "true",
            "node_user": "admin",
            "onboarding_password": "NokiaSrl1!",
            "onboarding_username": "admin",
            "license": os.environ.get("SRL_LICENSE", f"sros-ghcr-{sw_version}-dummy-license"),
        }
        sros_data = {
            "namespace": namespace,
            "profile_name": f"node-profile-sros-{sw_version}",
            "sw_version": sw_version,
            "gnmi_port": "57400",
            "operating_system": "sros",
            "version_path": ".system.information.version",
            "version_match": r"v24\.10\.r4.*",
            "yang_path": "https://eda-asvr.eda-system.svc/eda-system/schemaprofiles/sros-24.10.r4/sros-24.10.r4.zip",
            "annotate": "true",
            "node_user": "admin-sros",
            "onboarding_password": "NokiaSros1!",
            "onboarding_username": "admin",
            "license": os.environ.get("SROS_LICENSE", f"sros-ghcr-{sw_version}-dummy-license"),
        }
        for data in (srl_data, sros_data):
            prof_yaml = helpers.render_template("management/node-profile.j2", data)
            item = self.eda_client.add_replace_to_transaction(prof_yaml)
            if not self.eda_client.is_transaction_item_valid(item):
                raise ClabIntegratorError(
                    "Validation error creating node profile " + data["profile_name"]
                )

    def create_index_pools(self, namespace: str, resources_path: str | Path) -> None:
        """Create Index Allocation Pool resources from resources_path/indexpools/index_pool_data.json via transaction."""
        path = Path(resources_path) / "indexpools" / "index_pool_data.json"
        if not path.exists():
            raise ClabIntegratorError(f"Index pool data file not found: {path}")
        try:
            with path.open() as f:
                data = json.load(f)
        except OSError as e:
            raise ClabIntegratorError(f"Could not read index pool file {path}: {e}") from e
        except json.JSONDecodeError as e:
            raise ClabIntegratorError(f"Invalid JSON in index pool file {path}: {e}") from e
        for index_pool in data:
            name = index_pool.get("name", "?")
            logger.info(f"{SUBSTEP_INDENT}Adding index allocation pool: {name}")
            params = {**index_pool, "namespace": namespace}
            yml = helpers.render_template("allocations/index-pool.j2", params)
            item = self.eda_client.add_replace_to_transaction(yml)
            if not self.eda_client.is_transaction_item_valid(item):
                raise ClabIntegratorError(
                    f"Validation error for index pool {name}"
                )

    def create_ip_pools(self, namespace: str) -> None:
        """Create IP allocation pool resources via transaction."""
        templates = [
            ("allocations/systemipv4-pool.j2", "system IPv4 pool"),
            ("allocations/ipv4-mgmt-pool.j2", "IPv4 mgmt pool"),
            ("allocations/ipv4-pool.j2", "IPv4 pool"),
        ]
        for yaml_name, label in templates:
            logger.info(f"{SUBSTEP_INDENT}Adding {label}")
            params = {"namespace": namespace}
            yml = helpers.render_template(yaml_name, params)
            item = self.eda_client.add_replace_to_transaction(yml)
            if not self.eda_client.is_transaction_item_valid(item):
                raise ClabIntegratorError(
                    f"Validation error for {label}"
                )

    def create_fabric(self, namespace: str, resources_path: str | Path) -> None:
        """Create Fabric resource(s) from JSON under resources_path/fabric/ via transaction.
        Loads each JSON file as dict, renders with eda_integration/templates/resources/fabric.j2.
        """
        fabric_dir = Path(resources_path) / "fabric"
        if not fabric_dir.is_dir():
            raise ClabIntegratorError(f"Fabric directory not found: {fabric_dir}")
        items = read_resources(fabric_dir)
        if not items:
            raise ClabIntegratorError(f"No fabric JSON resources in {fabric_dir}")
        for data in items:
            data = dict(data)
            data["namespace"] = namespace
            logger.info(f"{SUBSTEP_INDENT}Adding fabric: {data.get('name', '?')}")
            yml = helpers.render_template("resources/fabric.j2", data)
            item = self.eda_client.add_replace_to_transaction(yml)
            if not self.eda_client.is_transaction_item_valid(item):
                logger.error(f"Validation error for fabric: {yml}")
                raise ClabIntegratorError("Validation error for fabric")

    def create_vnet(self, namespace: str, resources_path: str | Path) -> None:
        """Create Virtual Network resource(s) from JSON under resources_path/virtualnetwork/ via transaction.
        Loads each JSON file as dict, renders with eda_integration/templates/resources/virtualnetwork.j2.
        """
        vnet_dir = Path(resources_path) / "virtualnetwork"
        if not vnet_dir.is_dir():
            raise ClabIntegratorError(f"Virtual network directory not found: {vnet_dir}")
        items = read_resources(vnet_dir)
        if not items:
            raise ClabIntegratorError(f"No virtual network JSON resources in {vnet_dir}")
        for data in items:
            data = dict(data)
            data["namespace"] = namespace
            logger.info(f"{SUBSTEP_INDENT}Adding virtual network: {data.get('name', '?')}")
            yml = helpers.render_template("resources/virtualnetwork.j2", data)
            item = self.eda_client.add_replace_to_transaction(yml)
            if not self.eda_client.is_transaction_item_valid(item):
                logger.error(f"Validation error for virtual network: {yml}")
                raise ClabIntegratorError("Validation error for virtual network")

    def post_resources_from_directory(
        self, resources_dir: str | Path, namespace: str
    ) -> None:
        """
        Load JSON resources from toponode, topolink, interfaces_edge, interfaces_isl;
        render each with the corresponding eda_integration/templates/resources template,
        then apply via EDA transactions (replace), with toponodes in batches.
        Caller (run()) passes already-resolved resources path.
        """
        resources_path = Path(resources_dir)
        toponode_dir = resources_path / "toponode"
        if toponode_dir.is_dir():
            toponode_items = [
                {**item, "namespace": namespace}
                for item in read_resources(toponode_dir)
            ]
            toponode_yamls = [
                helpers.render_template("resources/toponode.j2", item)
                for item in toponode_items
            ] if toponode_items else []
            if toponode_yamls:
                self._post_toponodes_via_transactions(toponode_yamls)

        # xls_parser script sometimes creates duplicate edges with
        # same names as ISLs, so we need to deduplicate by resource name
        seen_interfaces = set()
        interface_yamls: list[str] = []
        for subdir in ("interfaces_isl", "interfaces_edge"):
            iface_dir = resources_path / subdir
            if iface_dir.is_dir():
                for item in read_resources(iface_dir):
                    iface_name = item.get("name")
                    if iface_name in seen_interfaces:
                        continue
                    seen_interfaces.add(iface_name)
                    data = {**item, "namespace": namespace}
                    yaml_str = helpers.render_template("resources/interface.j2", data)
                    interface_yamls.append(yaml_str)
        if interface_yamls:
            self._commit_yamls_via_transaction(
                interface_yamls,
                "create interfaces (edge + isl)",
                "topolink interface",
            )

        topolink_dir = resources_path / "topolink"
        if topolink_dir.is_dir():
            topolink_items = [
                {**item, "namespace": namespace}
                for item in read_resources(topolink_dir)
            ]
            topolink_yamls = [
                helpers.render_template("resources/topolink.j2", item)
                for item in topolink_items
            ] if topolink_items else []
            if topolink_yamls:
                self._commit_yamls_via_transaction(
                    topolink_yamls,
                    "create topolinks",
                    "topolink",
                )

    def _post_toponodes_via_transactions(self, yaml_strings: list[str]) -> None:
        """Post toponode YAML strings via transactions in batches."""
        if not yaml_strings:
            logger.info(f"{SUBSTEP_INDENT}No TopoNode YAMLs to post")
            return

        batch_size = TOPONODE_BATCH_SIZE
        batch_delay = TOPONODE_BATCH_DELAY_SEC
        total_batches = (len(yaml_strings) + batch_size - 1) // batch_size

        logger.info(
            f"{SUBSTEP_INDENT}Creating {len(yaml_strings)} TopoNodes in batches of {batch_size}"
        )

        for i in range(0, len(yaml_strings), batch_size):
            # Refetch auth token every N nodes to avoid expiry (~10 nodes)
            if (
                i >= TOPONODE_TOKEN_REFETCH_INTERVAL
                and (i // TOPONODE_TOKEN_REFETCH_INTERVAL)
                > ((i - batch_size) // TOPONODE_TOKEN_REFETCH_INTERVAL)
            ):
                logger.info(
                    f"{SUBSTEP_INDENT}Refetching auth token (after {i} nodes)..."
                )
                self.eda_client.login()

            batch = yaml_strings[i : i + batch_size]
            batch_num = (i // batch_size) + 1

            logger.info(
                f"{SUBSTEP_INDENT}Processing batch {batch_num}/{total_batches} ({len(batch)} nodes)..."
            )

            self._clear_transaction()
            for content in batch:
                if not content:
                    continue
                item = self.eda_client.add_replace_to_transaction(content)
                if not self.eda_client.is_transaction_item_valid(item):
                    raise ClabIntegratorError(
                        "Validation error creating toponode in transaction"
                    )

            try:
                self._commit_transaction(f"create nodes batch {batch_num}")
                logger.info(
                    f"{SUBSTEP_INDENT}Batch {batch_num}/{total_batches} committed successfully"
                )
            except Exception as e:
                logger.error(
                    f"Failed to commit batch {batch_num}/{total_batches}: {e}"
                )
                raise

            if i + batch_size < len(yaml_strings):
                logger.debug(
                    f"{SUBSTEP_INDENT}Waiting {batch_delay}s before next batch..."
                )
                time.sleep(batch_delay)

    def _commit_yamls_via_transaction(
        self,
        yaml_strings: list[str],
        description: str,
        resource_kind: str,
    ) -> None:
        """Add each YAML to a transaction, validate, then commit once."""
        self._clear_transaction()
        for yaml_str in yaml_strings:
            item = self.eda_client.add_replace_to_transaction(yaml_str)
            if not self.eda_client.is_transaction_item_valid(item):
                raise ClabIntegratorError(
                    f"Validation error for {resource_kind} in transaction"
                )
        self._commit_transaction(description)
        logger.info(f"{SUBSTEP_INDENT}{description}: {len(yaml_strings)} items")
