import asyncio
import base64
import json
import logging
import os
import re
import ssl
import subprocess
import tempfile
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Set, Tuple
from urllib.parse import urlparse

import httpx
import tenacity as tc
import yaml

from square.dtypes import (
    ConnectionParameters,
    K8sConfig,
    K8sResource,
    MetaManifest,
    SelKindGroupNames,
)

# Convenience: location of K8s credentials inside a Pod.
TOKENFILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/token")
CAFILE = Path("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")

# Define the exceptions we want to retry on.
WEB_EXCEPTIONS = (httpx.RequestError, ssl.SSLError, KeyError, TimeoutError)


# Convenience: global logger instance to avoid repetitive code.
logit = logging.getLogger("square")


def _on_backoff(retry_state: tc.RetryCallState):
    """Log a warning on each retry."""
    attempt = retry_state.attempt_number
    k8sconfig, method, url = retry_state.args[:3]
    path = urlparse(url).path

    logit.warning(f"Back off {attempt} - {k8sconfig.name} - {method} {path}.")


async def _mysleep(delay: float):
    """This trivial function exists to mock out the `sleep` call during tests."""
    await asyncio.sleep(delay)


@tc.retry(
    stop=(tc.stop_after_delay(300) | tc.stop_after_attempt(8)),
    wait=tc.wait_exponential(multiplier=1, min=0, max=20) + tc.wait_random(-5, 5),
    retry=tc.retry_if_exception_type(WEB_EXCEPTIONS),
    before_sleep=_on_backoff,
    reraise=True,
    sleep=_mysleep,
)
async def _call(
    k8sconfig: K8sConfig,
    method: str,
    url: str,
    payload: dict | list | None,
    headers: dict | None,
) -> httpx.Response:
    return await k8sconfig.client.request(method, url, json=payload, headers=headers)


async def request(
    k8sconfig: K8sConfig,
    method: str,
    url: str,
    payload: dict | list | None,
    headers: dict | None,
) -> Tuple[dict, int, bool]:
    """Return response of web request made with `client`.

    Inputs:
        client: HttpX client with correct K8s certificates.
        url: str
            Eg `https://1.2.3.4/api/v1/namespaces`)
        payload: dict
            Anything that can be JSON encoded, usually a K8s manifest.
        headers: dict
            Request headers. These will *not* replace the existing request
            headers dictionary (eg the access tokens), but augment them.

    Returns:
        (dict, int, bool): the JSON response and the HTTP status code.

    """
    # Make the HTTP request via our backoff/retry handler.
    try:
        ret = await _call(k8sconfig, method, url, payload=payload, headers=headers)
    except WEB_EXCEPTIONS as err:
        logit.error(f"Giving up - {k8sconfig.name} - {err} - {method} {url}")
        return ({}, -1, True)

    try:
        response = json.loads(ret.text)
    except json.decoder.JSONDecodeError as err:
        msg = (
            f"JSON error - {k8sconfig.name} - "
            f"{err.msg} in line {err.lineno} column {err.colno}",
            "-" * 80 + "\n" + err.doc + "\n" + "-" * 80,
        )
        logit.error(str.join("\n", msg))
        return ({}, ret.status_code, True)

    # Log the entire request in debug mode.
    logit.debug(
        f"{method} {ret.status_code} {ret.url}\n"
        f"Headers: {headers}\n"
        f"Payload: {payload}\n"
        f"Response: {response}\n"
    )
    return (response, ret.status_code, False)


async def delete(k8sconfig: K8sConfig, url: str, payload: dict) -> Tuple[dict, bool]:
    """Make DELETE requests to K8s (see `k8s_request`)."""
    resp, code, err = await request(k8sconfig, "DELETE", url, payload, headers=None)
    if err or code not in (200, 202):
        logit.error(f"{code} - DELETE - {url} - {resp}")
        return (resp, True)
    return (resp, False)


async def get(k8sconfig: K8sConfig, url: str) -> Tuple[dict, bool]:
    """Make GET requests to K8s (see `request`)."""
    resp, code, err = await request(k8sconfig, "GET", url, payload=None, headers=None)
    if err or code != 200:
        logit.error(f"{code} - GET - {url} - {resp}")
        return (resp, True)
    return (resp, False)


async def patch(
    k8sconfig: K8sConfig, url: str, payload: List[Dict[str, str]]
) -> Tuple[dict, bool]:
    """Make PATCH requests to K8s (see `request`)."""
    headers = {"Content-Type": "application/json-patch+json"}
    resp, code, err = await request(k8sconfig, "PATCH", url, payload, headers)
    if err or code != 200:
        logit.error(f"{code} - PATCH - {url} - {resp}")
        return (resp, True)
    return (resp, False)


async def post(k8sconfig: K8sConfig, url: str, payload: dict) -> Tuple[dict, bool]:
    """Make POST requests to K8s (see `request`)."""
    resp, code, err = await request(k8sconfig, "POST", url, payload, headers=None)
    err = code != 201
    if err:
        logit.error(f"{code} - POST - {url} - {resp}")
        return (resp, True)
    return (resp, False)


def load_kubeconfig(
    kubeconf_path: Path, context: str | None
) -> Tuple[str, dict, dict, bool]:
    """Return user name as well as user- and cluster information.

    Return None on error.

    Inputs:
        kubeconf_path: Path
            Path to kubeconfig file, eg "~/.kube/config.yaml"
        context: str | None
            Kubeconf context. Use `None` to select the default context.

    Returns:
        name, user info, cluster info

    """
    # Load `kubeconfig`.
    try:
        kubeconf = yaml.safe_load(open(kubeconf_path))
    except (IOError, PermissionError) as err:
        logit.error(f"{err}")
        return ("", {}, {}, True)

    # Find the user and cluster information based on the specified `context`.
    try:
        # Use default context unless specified.
        ctx = context if context else kubeconf["current-context"]

        try:
            # Find the correct context.
            ctx = [_ for _ in kubeconf["contexts"] if _["name"] == ctx]
            assert len(ctx) == 1
            ctx = ctx[0]["context"]

            # Unpack the cluster- and user name from the current context.
            clustername, username = ctx["cluster"], ctx["user"]

            # Find the information for the current cluster and user.
            user_info = [_ for _ in kubeconf["users"] if _["name"] == username]
            cluster_info = [_ for _ in kubeconf["clusters"] if _["name"] == clustername]
            assert len(user_info) == len(cluster_info) == 1
        except AssertionError:
            logit.error(f"Could not find context <{context}>")
            return ("", {}, {}, True)

        # Unpack the cluster- and user information.
        cluster_name = cluster_info[0]["name"]
        cluster_info_out = cluster_info[0]["cluster"]
        cluster_info_out["name"] = cluster_name
        user_info = user_info[0]["user"]
        del cluster_info
    except (KeyError, TypeError):
        logit.error(f"Kubeconfig YAML file <{kubeconf_path}> is invalid")
        return ("", {}, {}, True)

    # Success. The explicit `dicts()` are to satisfy MyPy.
    logit.info(f"Loaded {ctx} from Kubeconfig file <{kubeconf_path}>")
    return (username, dict(user_info), dict(cluster_info_out), False)


def load_incluster_config(
    tokenfile: Path = TOKENFILE, cafile: Path = CAFILE
) -> Tuple[K8sConfig, bool]:
    """Return K8s access config from Pod service account.

    Returns None if we are not running in a Pod.

    Inputs:
        kubconfig: str
            Name of kubeconfig file.
    Returns:
        K8sConfig

    """
    # These exist inside every Kubernetes pod.
    server_ip = os.getenv("KUBERNETES_PORT_443_TCP_ADDR", None)
    cafile = Path(cafile)
    tokenfile = Path(tokenfile)

    # Sanity checks: URL and service account must exist, or we are not running
    # inside a Pod.
    try:
        assert server_ip is not None
        assert cafile.exists()
        assert tokenfile.exists()
    except AssertionError:
        logit.debug("Could not find incluster (service account) credentials.")
        return K8sConfig(), True

    # Return the compiled K8s access configuration.
    logit.info("Use incluster (service account) credentials.")
    return K8sConfig(
        url=f"https://{server_ip}",
        token=tokenfile.read_text(),
        cadata=cafile.read_text(),
        cert=None,
        version="",
        name="",
    ), False


def run_external_command(cmd: List[str], env: Dict[str, str]) -> Tuple[str, str, bool]:
    """Call `cmd` and return the `stdout` response as a string."""
    # Upsert the new environment variables to the default ones.
    tmp_env = dict(os.environ) | env

    # Execute the program.
    try:
        out = subprocess.run(cmd, env=tmp_env, capture_output=True)
    except FileNotFoundError:
        return "", "", True

    # Return with an error unless the return code was zero.
    if out.returncode != 0:
        return "", out.stderr.decode("utf8"), True

    try:
        stdout = out.stdout.decode("utf8")
    except UnicodeDecodeError:
        return "", "", True

    return stdout, "", False


def load_authenticator_config(
    kubeconf_path: Path, context: str | None
) -> Tuple[K8sConfig, bool]:
    """Return K8s config based on authenticator app specified in `kubeconfig`.

    Returns None if `kubeconfig` does not exist or could not be parsed.

    Inputs:
        kubeconf_path: Path
            Path to kubeconfig file, eg "~/.kube/config.yaml"
        context: str | None
            Kubeconf context. Use `None` to select the default context.

    Returns:
        K8sConfig

    """
    # Parse the kubeconfig file.
    user, cluster, err = load_kubeconfig(kubeconf_path, context)[1:]
    if err:
        return (K8sConfig(), True)
    name = cluster["name"]

    # Get a copy of all env vars. We will pass that one along to the
    # sub-process, plus the env vars specified in the kubeconfig file.
    env = os.environ.copy()

    # Unpack the self signed certificate (server certs are not usually public).
    try:
        cadata = base64.b64decode(cluster["certificate-authority-data"]).decode()
        cmd = user["exec"]["command"]
        args = user["exec"].get("args", [])
        env_kubeconf = user["exec"].get("env", [])
    except KeyError:
        logit.debug(
            f"Context {context} in <{kubeconf_path}> does not use authenticator app"
        )
        return (K8sConfig(), True)

    # Convert a `None` to an empty list of env vars.
    # This happens when the kubeconfig file contains entries like `args: null`
    # in the YAML which will then be parsed as None in Python yet we need them
    # to be an empty list.
    env_kubeconf = env_kubeconf if env_kubeconf else []
    args = args if args else []

    # Compile the name, arguments and env vars for the command specified in kubeconf.
    cmd_args = [cmd] + args
    env_kubeconf = {_["name"]: _["value"] for _ in env_kubeconf}
    env.update(env_kubeconf)
    logit.debug(f"Authenticator app: {cmd_args} with envs: {env_kubeconf}")

    # Pre-format the command for the log message.
    log_cmd = (
        f"kubeconf={kubeconf_path} kubectx={context} cmd={cmd_args}  env={env_kubeconf}"
    )

    # Run the external tool to produce the access token. That program must
    # produce a YAML document on stdout that specifies the bearer token.
    stdout, stderr, err = run_external_command(cmd_args, env)
    if err:
        logit.error(f"Authenticator app error: {stderr} ({log_cmd})")
        return (K8sConfig(), True)

    try:
        token = yaml.safe_load(stdout)["status"]["token"]
    except (KeyError, yaml.YAMLError):
        logit.error(f"Token manifest produced by {cmd_args} is corrupt ({log_cmd})")
        return (K8sConfig(), True)
    except TypeError:
        logit.error(f"The YAML token produced by {cmd_args} is corrupt ({log_cmd})")
        return (K8sConfig(), True)

    # Return the Kubernetes access configuration.
    logit.info(f"Assuming generic cluster for {name}.")
    return K8sConfig(
        url=cluster["server"],
        name=cluster["name"],
        version="",
        cert=None,
        token=token,
        cadata=cadata,
    ), False


def load_minikube_config(
    kubeconf_path: Path, context: str | None
) -> Tuple[K8sConfig, bool]:
    """Load Minikube configuration from `fname`.

    Return None on error.

    Inputs:
        kubeconf_path: Path
            Path to kubeconfig file, eg "~/.kube/config.yaml"
        context: str | None
            Kubeconf context. Use `None` to select the default context.

    Returns:
        K8sConfig

    """
    # Parse the kubeconfig file.
    _, user, cluster, err = load_kubeconfig(kubeconf_path, context)
    if err:
        return (K8sConfig(), True)
    name = cluster["name"]

    # Minikube uses client certificates to authenticate. We need to pass those
    # to the HTTP client of our choice
    try:
        # Return the Kubernetes access configuration.
        logit.info(f"Assuming Minikube cluster for {name}")
        return K8sConfig(
            url=cluster["server"],
            token="",
            cadata=Path(cluster["certificate-authority"]).read_text(),
            cert=(Path(user["client-certificate"]), Path(user["client-key"])),
            version="",
            name=name,
        ), False
    except KeyError:
        logit.debug(f"Context {context} in <{kubeconf_path}> is not a Minikube config")
        return (K8sConfig(), True)


def load_kind_config(
    kubeconf_path: Path, context: str | None
) -> Tuple[K8sConfig, bool]:
    """Load Kind configuration from `fname`.

    https://github.com/bsycorp/kind

    Kind is just another Minikube cluster. The only notable difference
    is that it does not store its credentials as files but directly in
    the Kubeconfig file. This function will copy those files into /tmp.

    Return None on error.

    Inputs:
        kubeconf_path: Path
            Path to kubeconfig file, eg "~/.kube/config.yaml"
        context: str | None
            Kubeconf context. Use `None` to select the default context.

    Returns:
        K8sConfig

    """
    # Parse the kubeconfig file.
    _, user, cluster, err = load_kubeconfig(kubeconf_path, context)
    if err:
        return (K8sConfig(), True)
    name = cluster["name"]

    # Kind and Minikube use client certificates to authenticate. We need to
    # save them to files before we can pass them to HttpX.
    try:
        cadata = base64.b64decode(cluster["certificate-authority-data"]).decode()

        path = Path(tempfile.mkdtemp())
        client_crt = base64.b64decode(user["client-certificate-data"]).decode()
        client_key = base64.b64decode(user["client-key-data"]).decode()
        p_client_crt = path / "kind-client.crt"
        p_client_key = path / "kind-client.key"
        p_client_crt.write_text(client_crt)
        p_client_key.write_text(client_key)

        # Return the config data.
        logit.debug(f"Assuming Kind cluster for {name}.")
        return K8sConfig(
            url=cluster["server"],
            token=user.get("token", ""),
            cadata=cadata,
            cert=(p_client_crt, p_client_key),
            version="",
            name=name,
        ), False
    except KeyError:
        logit.debug(f"Context {context} in <{kubeconf_path}> is not a Minikube config")
        return (K8sConfig(), True)


def load_auto_config(
    kubeconf_path: Path, context: str | None
) -> Tuple[K8sConfig, bool]:
    """Automagically find and load the correct K8s configuration.

    This function will sequentially load all supported authentication schemes
    until one fits.

    1) `load_authenticator_config`
    2) `load_incluster_config`
    3) `load_kind_config`
    4) `load_minikube_config`

    Inputs:
        kubeconf_path: Path
            Path to kubeconfig file, eg "~/.kube/config.yaml"
        context: str | None
            Kubeconf context. Use `None` to select the default context.

    Returns:
        K8sConfig

    """
    conf, err = load_authenticator_config(kubeconf_path, context)
    if not err:
        return conf, False
    logit.debug("Authenticator config failed")

    conf, err = load_incluster_config()
    if not err:
        return conf, False
    logit.debug("Incluster config failed")

    conf, err = load_kind_config(kubeconf_path, context)
    if not err:
        return conf, False
    logit.debug("KIND config failed")

    conf, err = load_minikube_config(kubeconf_path, context)
    if not err:
        return conf, False
    logit.debug("Minikube config failed")

    logit.error(f"Could not find a valid configuration in <{kubeconf_path}>")
    return (K8sConfig(), True)


def create_ssl_context(cadata: str | None, disable_x509_strict: bool) -> ssl.SSLContext:
    """Create an SSLContext with custom CA and control over X.509 strict verification.

    Inputs:
      cadata : str or None
          Optional string containing CA certificates in PEM format.
      disable_x509_strict : bool
          Disables strict X.509 certificate verification.

    Returns
      ssl.SSLContext for HttpX

    Notes
    -----
    Disabling X.509 strict verification may be necessary for compatibility with
    legacy servers or Kubernetes clusters (e.g., older EKS clusters) that use
    non-compliant certificates.
    https://github.com/aws/containers-roadmap/issues/2638

    """
    sslcontext = ssl.create_default_context(cadata=cadata)

    if disable_x509_strict and hasattr(ssl, "VERIFY_X509_STRICT"):
        sslcontext.verify_flags &= ~ssl.VERIFY_X509_STRICT

    return sslcontext


def create_httpx_client(
    k8sconfig: K8sConfig, conparam: ConnectionParameters
) -> Tuple[K8sConfig, bool]:
    """Return configured HttpX client."""
    # Configure Httpx client with the K8s service account token.
    sslcontext = create_ssl_context(k8sconfig.cadata, conparam.disable_x509_strict)

    # Compile an HttpX `Timeout` instance.
    timeout = httpx.Timeout(
        connect=conparam.connect,
        read=conparam.read,
        write=conparam.write,
        pool=conparam.pool,
    )

    limits = httpx.Limits(
        max_keepalive_connections=conparam.max_keepalive_connections,
        max_connections=conparam.max_connections,
        keepalive_expiry=conparam.keepalive_expiry,
    )

    # Construct the HttpX client.
    try:
        transport = httpx.AsyncHTTPTransport(
            verify=sslcontext,
            cert=k8sconfig.cert,  # type: ignore
            retries=0,  # Square has its own retry logic.
            http1=conparam.http1,
            http2=conparam.http2,
        )
        client = httpx.AsyncClient(timeout=timeout, transport=transport, limits=limits)
    except ssl.SSLError:
        logit.error(f"Invalid certificates for {k8sconfig.name}")
        return k8sconfig, True
    except FileNotFoundError:
        # If the certificate files do not exist then we have a bug somewhere.
        logit.error(f"Bug: certificate files do not exist for {k8sconfig.name}")
        return k8sconfig, True

    # Add the bearer token if we have one.
    headers = {"authorization": f"Bearer {k8sconfig.token}"} if k8sconfig.token else {}
    client.headers.update(headers)

    # Merge in user supplied headers.
    client.headers.update(conparam.k8s_extra_headers)

    # Add the web client to the `k8sconfig` object.
    k8sconfig = k8sconfig.model_copy(update=dict(client=client, headers=headers))

    # Return the configured client object.
    return k8sconfig, False


def resource(
    k8sconfig: K8sConfig, gvknn: SelKindGroupNames | MetaManifest
) -> Tuple[K8sResource, bool]:
    """Return the `K8sResource` object that corresponds to `meta`.

    That object will contain the full path to a resource, eg.
    https://1.2.3.4/api/v1/namespace/foo/services.

    Inputs:
        k8sconfig: K8sConfig
        GVKN: can be either a MetaManifest or SelKindGroupNames

    Returns:
        K8sResource

    """
    err_resp = (K8sResource("", "", "", False, "", tuple()), True)

    # Retrieve the specific `K8sResource` for the specified
    # group/version/kind/namespace/name.
    resource, err = pick_api(gvknn, k8sconfig.apis)
    if err:
        return err_resp

    # Convenience.
    namespace, name = gvknn.namespace, gvknn.name

    # Determine the prefix for namespaced resources.
    if not (namespace and resource.namespaced):
        namespace = ""
    else:
        # Namespace name must conform to K8s standards.
        match = re.match(r"[a-z0-9]([-a-z0-9]*[a-z0-9])?", namespace)
        if match is None or match.group() != namespace:
            logit.error(f"Invalid namespace name <{namespace}>.")
            return err_resp
        namespace = f"namespaces/{namespace}"

    # Sanity check: the manifest for a namespaced resource must specify a namespace.
    if resource.namespaced and name and not namespace:
        logit.error(f"Namespaced resource {gvknn.kind}/{name} lacks namespace field")
        return err_resp

    # Create the full path to the resource depending on whether we have both a
    # namespace and resource name or just the resource name. In the latter
    # case, we will fetch the resource from all namespaces. These are the
    # possibilities:
    #  - /api/v1/services
    #  - /api/v1/namespaces/my-namespace/services
    #  - /api/v1/namespaces/my-namespace/services/my-service
    path = f"{namespace}/{resource.name}" if namespace else resource.name
    path = f"{path}/{name}" if name else path

    # The concatenation above may have introduced `//`. Remove them.
    path = path.replace("//", "/")

    # Return the `K8sResource` with the correct URL.
    resource = resource._replace(url=f"{resource.url}/{path}")
    return resource, False


async def version(k8sconfig: K8sConfig) -> Tuple[K8sConfig, bool]:
    """Return copy of `k8sconfig` but with current Kubernetes version.

    All other fields in the provided `k8sconfig` will remain the same.

    Inputs:
        k8sconfig: K8sConfig

    Returns:
        K8sConfig

    """
    # Ask the K8s API for its version and check for errors.
    url = f"{k8sconfig.url}/version"
    resp, err = await get(k8sconfig, url)
    if err or resp is None:
        logit.error(f"Could not interrogate {k8sconfig.name} ({url})")
        return (K8sConfig(), True)

    # Construct the version number of the K8s API.
    major, minor = resp["major"], resp["minor"]
    version = f"{major}.{minor}"

    # Return an updated `K8sconfig` tuple.
    k8sconfig = k8sconfig.model_copy(update=dict(version=version))
    return (k8sconfig, False)


async def cluster_config(
    kubeconfig: Path, context: str | None, conparams: ConnectionParameters
) -> Tuple[K8sConfig, bool]:
    """Return the `K8sConfig` to connect to the API.

    This will read the Kubernetes credentials, create a client and use it to
    fetch the Kubernetes version.

    Inputs:
        kubeconfig: Path
            Path to kubeconfig file.
        context: str
            Kubernetes context to use (can be `None` to use default).

    Returns:
        K8sConfig

    """
    # Create a HttpX client based on the Kubeconfig file. It will have the
    # proper certificates and headers to connect to K8s.
    kubeconfig = kubeconfig.expanduser()
    try:
        # Parse Kubeconfig file.
        k8sconfig, err = load_auto_config(kubeconfig, context)
        assert not err

        # Configure a HttpX client for this cluster.
        k8sconfig, err = create_httpx_client(k8sconfig, conparams)
        assert not err

        # Contact the K8s API to update version field in `k8sconfig`.
        k8sconfig, err = await version(k8sconfig)
        assert not err and k8sconfig

        # Populate the `k8sconfig.apis` field.
        err = await compile_api_endpoints(k8sconfig)
        assert not err
    except AssertionError:
        return (K8sConfig(), True)

    # Log the K8s API address and version.
    logit.info(
        f"name: {k8sconfig.name}  url {k8sconfig.url}  version {k8sconfig.version}"
    )
    return (k8sconfig, False)


def parse_api_group(gv, url: str, resp: dict) -> List[K8sResource]:
    """Compile the K8s API `resp` into a `K8sResource` tuple.

    The `resp` is the verbatim response from the K8s API group regarding the
    resources it provides. Here we compile those into `K8sResource` tuples iff
    they meet the criteria to be manageable by Square. These criteria are, most
    notably, the ability to create, get, patch and delete the resource.

    """
    resources = resp["resources"]

    def valid(_res):
        """Return `True` if `res` describes a Square compatible resource."""
        # Convenience.
        name = _res["name"]
        verbs = list(sorted(_res["verbs"]))

        # Ignore resources like "services/status". We only care for "services".
        if "/" in name:
            logit.debug(f"Ignore resource <{name}>: has a slash ('/') in its name")
            return False

        # Square can only manage the resource if it can be read, modified and
        # deleted. Here we check if `res` has the respective verbs.
        minimal_verbs = {"create", "delete", "get", "list", "patch", "update"}
        if not minimal_verbs.issubset(set(verbs)):
            logit.debug(f"Ignore resource <{name}>: insufficient verbs: {verbs}")
            return False

        return True

    # Compile the K8s resource definition into a `K8sResource` structure if it
    # is compatible with Square (see `valid` helper above).
    group_urls: List[K8sResource] = []
    for res in resources:
        if not valid(res):
            continue

        kind, name, namespaced = res["kind"], res["name"], res["namespaced"]
        all_names = [name, res["singularName"]] + res.get("shortNames", [])

        # Remove duplicates and empty strings that may occur when no
        # `singularName` is specified. Furthermore, ensure that all names are
        # lower case. They should be already, but just to be sure.
        all_names = set(all_names)
        all_names.discard("")
        tan = tuple(sorted([_.lower() for _ in all_names]))

        group_urls.append(K8sResource(gv, kind, name, namespaced, url, tan))

    return group_urls


async def compile_api_endpoints(k8sconfig: K8sConfig) -> bool:
    """Populate `k8sconfig.apis` with all the K8s endpoints.

    NOTE: replaces `k8sconfig.apis` in its entirety.

    The `k8sconfig.apis` will look like this:
    {
      configmaps: K8sResource(
        apiVersion=v1, kind='ConfigMap', name='configmaps', namespaced=True,
        url='https://localhost:8443/api/v1/configmaps'),
      cronjob: K8sResource(
        apiVersion='batch/v1beta1', kind='CronJob', name='cronjobs', namespaced=True,
        url='https://localhost:8443/apis/batch/v1beta1/cronjobs'),
      cronjob.batch: K8sResource(
        apiVersion='batch/v1beta1', kind='CronJob', name='cronjobs', namespaced=True,
        url='https://localhost:8443/apis/batch/v1beta1/cronjobs'),
      cronjob.batch/v1: K8sResource(
        apiVersion='batch/v1beta1', kind='CronJob', name='cronjobs', namespaced=True,
        url='https://localhost:8443/apis/batch/v1beta1/cronjobs'),
    }

    Inputs:
        k8sconfig: K8sConfig

    """
    # Compile the list of all K8s API groups that this K8s instance knows about.
    resp, err = await get(k8sconfig, f"{k8sconfig.url}/apis")
    if err:
        logit.error(f"Could not interrogate {k8sconfig.name} ({k8sconfig.url}/apis)")
        return True

    # Determine the set of preferred groups. We will use this to mark the
    # corresponding `K8sResource` instance.
    preferred: Set[str] = {
        _["preferredVersion"]["groupVersion"] for _ in resp["groups"]
    }

    # Determine the URL where we can get information for each group and version.
    all_groups = [("v1", f"{k8sconfig.url}/api/v1")]
    for group in resp["groups"]:
        for version in group["versions"]:
            gv = version["groupVersion"]
            url = f"{k8sconfig.url}/apis/{gv}"
            all_groups.append((gv, url))
            del gv, url, version
        del group

    # Fetch information about each API group and extract their resources.
    resources: List[K8sResource] = []
    for gv, url in all_groups:
        resp, err = await get(k8sconfig, url)
        if err:
            logit.error(f"Could not interrogate {k8sconfig.name} ({url})")
            return True

        resources.extend(parse_api_group(gv, url, resp))
        del gv, url, resp, err

    # Partition the APIs into the list of native (eg Pod, Service) and
    # non-native ones (everything else, like Deployment).
    res_not_v1 = [_ for _ in resources if _.apiVersion != "v1"]
    res_v1 = [_ for _ in resources if _.apiVersion == "v1"]
    del resources

    # Convenience only (no technical reason but was helpful during debugging).
    res_v1.sort(key=lambda _: _.apiVersion)
    res_not_v1.sort(key=lambda _: _.apiVersion)

    # Temporary buffer that will be inserted into `k8sconfig.apis` later.
    apis: Dict[str, List[K8sResource]]
    apis = defaultdict(list)

    # Map each resource kind name to the correct K8sResource.
    for res in res_not_v1:
        # Update the `preferred` flag if necessary.
        if res.apiVersion in preferred:
            res = res._replace(preferred=True)

        # Convenience: gv=apps/v1  group=apps
        gv = res.apiVersion
        group = gv.partition("/")[0]

        # Create an entry in our `apis` table for possible ways to name a
        # resource. Example:
        # {"deploy", "deployment", "deployments"} x {"", ".apps", ".apps/v1"}
        for name in res.aliases:
            apis[name].append(res)
            apis[f"{name}.{group}"].append(res)
            apis[f"{name}.{gv}"].append(res)

    # Repeat for v1 resources. Their `preferred` flag is always set because the
    # native resources are special and will ever only exist in v1. Technically, they
    # are not in any group but for convenience in other places we will pretend
    # they exist in group "v1" but no version. The net effect is that the user
    # can specify "pod" or "pod.v1", but not "pod/v1" or "pod.v1/v1".
    for res in res_v1:
        res = res._replace(preferred=True)

        for name in res.aliases:
            apis[name].clear()
            apis[name].append(res)

            apis[name + ".v1"].clear()
            apis[name + ".v1"].append(res)

    # Replace the existing apis.
    k8sconfig.apis.clear()
    k8sconfig.apis.update(dict(apis))

    return _validate_apis(k8sconfig.apis)


def _validate_apis(apis: Dict[str, List[K8sResource]]) -> bool:
    """Validate all the `apis` and return an error if it finds something inconsistent.

    NOTE: the caller should pass `k8sconfig.apis` for the `apis` variable.

    """
    err = False
    for name, resources in apis.items():
        # Is the name fully qualified (ie `kind.group/version`)?
        if "/" in name:
            if len(resources) != 1:
                err = True
                tmp = str.join(", ", [_.apiVersion for _ in resources])
                logit.critical(
                    f"bug: API <{name}> must contain exactly one resource "
                    f"but contains {len(resources)}: {tmp}"
                )
                continue
        else:
            # Each API resource must contain at least one resource.
            if len(resources) == 0:
                err = True
                logit.warning(f"bug: resource <{name}> has no preferred version")
                continue

            # Warn if we have multiple preferred version. That usually means the resource
            # name is ambiguous and multiple groups provide one of its kind.
            preferred = [_ for _ in resources if _.preferred]
            if len(preferred) > 1:
                tmp = str.join(", ", [_.apiVersion for _ in preferred])
                N = len(preferred)
                logit.warning(f"<{name}> has {N} preferred versions: {tmp}")
                continue

        # Sanity check: the resource name is eg `deploy.apps/v1`. Here we
        # ensure that the first part (ie `deploy`) is one of the aliases.
        name = name.partition(".")[0]
        for res in resources:
            if name not in res.aliases:
                err = True
                logit.critical(f"bug: <{name}> does not belong to resource")

    return err


def pick_api(
    kgn: SelKindGroupNames | MetaManifest, apis: Dict[str, List[K8sResource]]
) -> Tuple[K8sResource, bool]:
    """Selects the most appropriate API version for a given Kubernetes resource name.

    Args:
        meta: the Kubernetes resource to match to an API.
        apis: should be `k8sconfig.apis`.

    Returns:
        - The selected K8sResource object.
        - Error flag.

    Selection logic in this order:
        - Return the preferred group (this should be the case almost always).
        - Pick the latest version:
            a. Stable (e.g., v1, v2, ...)
            b. Beta (e.g., v1beta1, v2beta2, ...)
            c. Alpha (e.g., v1alpha1, v2alpha2, ...)
        - For non-standard version names, return the last one in alphabetical order.

    """
    # Convenience.
    err_resp = (K8sResource("", "", "", False, "", tuple()), True)

    # Construct the `kind.group/version` string depending on the type of `kgn`.
    if isinstance(kgn, MetaManifest):
        name = kgn.kind.lower()
        if kgn.apiVersion:
            name += "." + kgn.apiVersion
    else:
        name = kgn.kind_group

    # Return an error if the resource does not exist in the cluster.
    if name not in apis:
        logit.error(f"resource <{name}> does not exist")
        return err_resp

    # Return an error if the resource is provided by multiple API groups.
    groups = {_.apiVersion.partition("/")[0] for _ in apis[name]}
    if len(groups) > 1:
        msg = sorted([f"{name}.{_}" for _ in groups])
        logit.error(
            f"resource <{name}> is provided by multiple groups. "
            f"Please use one of: {msg}"
        )
        return err_resp

    # Return the preferred group if there is exactly one.
    preferred = [_ for _ in apis[name] if _.preferred]
    if len(preferred) == 1:
        return preferred[0], False
    elif len(preferred) > 1:
        logit.error(f"resource <{name}> has multiple preferred versions: {preferred}")
        return err_resp

    # If we got here it means we have no preferred API version. In that case,
    # we will use a heuristic to determine, in this order, the highest stable,
    # beta or alpha version.
    api_dict = {_.apiVersion.partition("/")[2]: _ for _ in apis[name]}

    p_stable = re.compile(r"^v\d+$")
    p_alpha = re.compile(r"^v\d+alpha\d+$")
    p_beta = re.compile(r"^v\d+beta\d+$")
    stable = [_ for _ in api_dict if p_stable.match(_)]
    alpha = [_ for _ in api_dict if p_alpha.match(_)]
    beta = [_ for _ in api_dict if p_beta.match(_)]

    stable.sort()
    alpha.sort()
    beta.sort()
    if len(stable) > 0:
        pick = api_dict[stable[-1]]
        logit.info(f"Picked {pick.apiVersion} for <{name}>")
        return pick, False

    if len(beta) > 0:
        pick = api_dict[beta[-1]]
        logit.info(f"Picked {pick.apiVersion} for <{name}>")
        return pick, False

    if len(alpha) > 0:
        pick = api_dict[alpha[-1]]
        logit.info(f"Picked {pick.apiVersion} for <{name}>")
        return pick, False

    # If we could not find any canonically named stable/alpha/beta version then
    # we simply pick the last one in alphabetical order.
    other = tuple(sorted(api_dict))
    pick = api_dict[other[-1]]
    logit.warning(
        f"could not find canonical version for <{name}>. Picked {pick.apiVersion} instead"
    )
    return pick, False
