import datetime
import json
import logging
import os
import tarfile
import tempfile
import zipfile
from pathlib import Path
from textwrap import dedent

import requests
import yaml

from sdcm.remote import LocalCmdRunner
from sdcm.utils.common import get_free_port, list_logs_by_test_id, S3Storage, remove_files
from sdcm.utils.decorators import retrying
from sdcm.utils.session import create_retry_session

from sdcm.monitorstack.constants import (
    ALERT_DOCKER_NAME,
    ALERT_DOCKER_PORT,
    COMMAND_TIMEOUT,
    GRAFANA_DOCKER_NAME,
    GRAFANA_DOCKER_PORT,
    PROMETHEUS_DOCKER_NAME,
    PROMETHEUS_DOCKER_PORT,
)
from sdcm.monitorstack.verify import verify_monitoring_stack

LOGGER = logging.getLogger(name="monitoringstack")


class ErrorUploadSCTDashboard(Exception):
    pass


class ErrorUploadAnnotations(Exception):
    pass


def get_monitoring_container_ports() -> set[int]:
    """Return host ports already claimed by monitoring stack containers.

    Queries Docker for containers whose names follow the monitoring stack naming
    (agraf-PORT, aprom-PORT, aalert-PORT) and extracts their port numbers.
    """
    result = LocalCmdRunner().run("docker ps -a --format '{{.Names}}'", ignore_status=True, verbose=False)
    if not result.ok or not result.stdout.strip():
        return set()

    prefixes = (f"{GRAFANA_DOCKER_NAME}-", f"{PROMETHEUS_DOCKER_NAME}-", f"{ALERT_DOCKER_NAME}-")
    ports = set()
    for raw_name in result.stdout.strip().splitlines():
        container_name = raw_name.strip()
        for prefix in prefixes:
            if container_name.startswith(prefix):
                try:
                    ports.add(int(container_name[len(prefix) :]))
                except ValueError:
                    pass
                break
    return ports


def restore_monitoring_stack(test_id, date_time=None):  # noqa: PLR0911
    if not is_docker_available():
        return False

    arch = get_monitoring_stack_archive(test_id, date_time)
    if not arch:
        return False

    LOGGER.info("Restoring monitoring stack from archive %s", arch["file_path"])
    monitoring_stack_base_dir = tempfile.mkdtemp()
    LOGGER.info("Download file {} to directory {}".format(arch["link"], monitoring_stack_base_dir))

    try:
        downloaded_monitoring_archive = S3Storage().download_file(arch["link"], dst_dir=monitoring_stack_base_dir)
    except RuntimeError as exc:
        LOGGER.error("Failed to download monitoring stack archive: %s", exc)
        return False

    monitoring_data_arch = extract_monitoring_data_archive(downloaded_monitoring_archive, monitoring_stack_base_dir)
    monitoring_stack_arch = extract_monitoring_stack_archive(downloaded_monitoring_archive, monitoring_stack_base_dir)

    if not monitoring_data_arch:
        LOGGER.error("No prometheus snapshot were found in arch %s", arch["file_path"])
        return False
    if not monitoring_stack_arch:
        LOGGER.error("No monitoring stack archive were found in arch %s", arch["file_path"])
        return False

    if sorted(monitoring_data_arch) != sorted(monitoring_stack_arch):
        LOGGER.error(
            "Prometheus data is not fitting to to monitoring stack. Clusters are not same."
            "\n Prometheus data for clusters: %s"
            "\nMonitoring stack for clusters: %s",
            monitoring_data_arch,
            monitoring_stack_arch,
        )
        return False

    monitors_containers = {}
    for monitoring_cluster, monitoring_arch in monitoring_stack_arch.items():
        LOGGER.info("Creating monitoring stack directories for %s cluster", monitoring_cluster)
        monitoring_data_dir = create_monitoring_data_dir(
            monitoring_stack_base_dir, monitoring_data_arch[monitoring_cluster], tenant_dir=monitoring_cluster
        )
        monitoring_stack_dir = create_monitoring_stack_dir(monitoring_stack_base_dir, monitoring_arch)

        if not monitoring_stack_dir or not monitoring_data_dir:
            LOGGER.error(
                "Creating monitoring stack directories failed:\ndata_dir: %s; stack_dir: %s",
                monitoring_data_dir,
                monitoring_stack_dir,
            )
        _, scylla_version = get_monitoring_stack_scylla_version(monitoring_stack_dir)

        containers_ports = run_monitoring_stack_containers(
            monitoring_stack_dir, monitoring_data_dir, scylla_version, tenants_number=len(monitoring_stack_arch)
        )
        if not containers_ports:
            return False

        dashboard_file = get_nemesis_dashboard_file_for_cluster(
            base_dir=monitoring_stack_base_dir,
            archive=monitoring_arch,
            file_name_for_search="scylla-dash-per-server-nemesis",
        )
        LOGGER.info("Nemesis dashboard file for the cluster %s is '%s'", monitoring_cluster, dashboard_file)
        status = restore_grafana_dashboards_and_annotations(
            monitoring_stack_dir,
            grafana_docker_port=containers_ports["grafana_docker_port"],
            sct_dashboard_file=dashboard_file,
        )
        if not status:
            return False

        status = verify_monitoring_stack(containers_ports=containers_ports)
        if not status:
            remove_files(monitoring_stack_base_dir)
            return False

        LOGGER.info("Monitoring stack for cluster %s is running", monitoring_cluster)
        monitors_containers[monitoring_cluster] = containers_ports
    return monitors_containers


def get_monitoring_stack_archive(test_id, date_time):
    """Return monitor_set archive file info.

    Choose from list of archives files stored on S3 for test_run with test_id.
    Only monitor-set archives sorted by date_time of archive creation.
    If date_time provided return archive file info for found files,
    otherwise return latest archive file info.
    """
    arch = None

    stored_files_by_test_id = list_logs_by_test_id(test_id)
    monitoring_stack_archives = [arch for arch in stored_files_by_test_id if "monitor-set" in arch["type"]]
    if not monitoring_stack_archives:
        LOGGER.warning("No any archive for monitoring stack")
        return False

    if date_time:
        found_archives = [
            archive
            for archive in monitoring_stack_archives
            if archive["date"] == datetime.datetime.strptime(date_time, "%Y%m%d_%H%M%S")
        ]
        if not found_archives:
            LOGGER.warning("Monitoring stack archive for date %s was not found", date_time)
        else:
            arch = found_archives[-1]
    else:
        arch = monitoring_stack_archives[-1]

    return arch


def create_monitoring_data_dir(base_dir, archive, tenant_dir=""):
    monitoring_data_base_dir = os.path.join(base_dir, "monitoring_data_dir")
    if tenant_dir:
        monitoring_data_base_dir = os.path.join(monitoring_data_base_dir, tenant_dir)

    cmd = dedent(
        f"""
        mkdir -p {monitoring_data_base_dir}
        cd {monitoring_data_base_dir}
        cp {archive} ./
        tar -xvf {os.path.basename(archive)}
        chmod -R 777 {monitoring_data_base_dir}
        """
    )
    result = LocalCmdRunner().run(cmd, timeout=COMMAND_TIMEOUT, ignore_status=True)
    if result.exited > 0:
        LOGGER.error("Error during extracting prometheus snapshot. Switch to next archive")
        return False
    return get_monitoring_data_dir(monitoring_data_base_dir)


def get_nemesis_dashboard_file_for_cluster(base_dir, archive, file_name_for_search):
    cmd = f'tar -tf {archive} | grep "{file_name_for_search}"'

    result = LocalCmdRunner().run(cmd, timeout=COMMAND_TIMEOUT, ignore_status=True)
    if result.exited > 0:
        LOGGER.error("Error during extracting monitoring stack")
        return None

    dashboard_file = result.stdout.strip()
    if not dashboard_file:
        return None

    dashboard_file = Path(base_dir) / dashboard_file
    if not dashboard_file.exists():
        return None

    return dashboard_file


def create_monitoring_stack_dir(base_dir, archive):
    cmd = dedent(
        f"""
        cd {base_dir}
        cp {archive} ./
        tar -xvf {os.path.basename(archive)}
        chmod -R 777 {base_dir}
        """
    )

    result = LocalCmdRunner().run(cmd, timeout=COMMAND_TIMEOUT, ignore_status=True)
    if result.exited > 0:
        LOGGER.error("Error during extracting monitoring stack")
        return False

    return get_monitoring_stack_dir(base_dir)


def extract_monitoring_stack_archive(downloaded_monitor_set_archive, monitoring_stack_base_dir):
    return extract_file_from_archive("monitoring_data_stack", downloaded_monitor_set_archive, monitoring_stack_base_dir)


def extract_monitoring_data_archive(downloaded_monitor_set_archive, monitoring_stack_base_dir):
    return extract_file_from_archive("prometheus_data", downloaded_monitor_set_archive, monitoring_stack_base_dir)


def extract_file_from_archive(pattern, archive, extract_dir) -> dict[str, str | Path]:
    if archive.endswith(".zip"):
        return extract_file_from_zip_archive(pattern, archive, extract_dir)
    if archive.endswith(".tar.gz"):
        return extract_file_from_tar_archive(pattern, archive, extract_dir)
    if archive.endswith(".tar.zst"):
        return extract_file_from_tar_zstd(pattern, archive, extract_dir)
    raise ValueError(f"Not supported archive type - {archive.split('.')[-1]}")


def extract_file_from_zip_archive(pattern, archive, extract_dir):
    found_file = None
    with zipfile.ZipFile(archive) as zfile:
        for name in zfile.namelist():
            if pattern in name:
                target_dir = os.path.join(extract_dir, name)
                if is_path_outside_of_dir(target_dir, extract_dir):
                    LOGGER.warning("Skipping %s file it leads to outside of the target dir", name)
                    continue
                zfile.extract(name, extract_dir)
                found_file = target_dir
                break
    return found_file


def is_path_outside_of_dir(path, base) -> bool:
    real_base = os.path.realpath(base)
    return os.path.commonpath((os.path.realpath(path), real_base)) != real_base


def extract_file_from_tar_archive(pattern, archive, extract_dir):
    found_file = {}
    with tarfile.open(archive) as tar_file:
        for name in tar_file.getnames():
            if pattern in name:
                target_dir = os.path.join(extract_dir, name)
                if is_path_outside_of_dir(target_dir, extract_dir):
                    LOGGER.warning("Skipping %s file it leads to outside of the target dir", name)
                    continue
                tar_file.extract(name, extract_dir)
                found_file[os.path.dirname(target_dir).split("/")[-1]] = target_dir
    return found_file


def extract_file_from_tar_zstd(pattern, archive, extract_dir) -> dict[str, str | Path]:
    found_file = {}
    result = LocalCmdRunner().run(f"tar --zstd -tf {archive}")
    if not result.ok:
        LOGGER.error("Error listing archive contents: %s", result.stderr)
    else:
        for name in result.stdout.splitlines():
            if pattern in name:
                target_path = Path(extract_dir) / name
                if is_path_outside_of_dir(target_path, extract_dir):
                    LOGGER.warning("Skipping %s file it leads to outside of the target dir", name)
                    continue
                extract_result = LocalCmdRunner().run(f"tar --zstd -xvf '{archive}' -C '{extract_dir}' '{name}'")
                if not extract_result.ok:
                    LOGGER.error("Error extracting file %s: %s", name, extract_result.stderr)
                else:
                    found_file[target_path.parent.name] = target_path
    return found_file


def get_monitoring_data_dir(base_dir):
    monitoring_data_dirs = [d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d))]
    if not monitoring_data_dirs:
        LOGGER.error("%s is empty. No data found", base_dir)
        return False
    return os.path.join(base_dir, monitoring_data_dirs[0])


def get_monitoring_stack_dir(base_dir):
    monitoring_stack_dir = [d for d in os.listdir(base_dir) if "scylla-monitoring" in d]
    if not monitoring_stack_dir:
        LOGGER.error("%s is empty. No data found", base_dir)
        return False
    return os.path.join(base_dir, monitoring_stack_dir[0])


def get_monitoring_stack_scylla_version(monitoring_stack_dir):
    try:
        with open(os.path.join(monitoring_stack_dir, "monitor_version"), encoding="utf-8") as f:
            versions = f.read().strip()
        monitoring_version, scylla_version = versions.split(":")
        if not requests.head(
            f"https://github.com/scylladb/scylla-monitoring/tree/{monitoring_version}"
            f"/grafana/build/ver_{scylla_version}"
        ).ok:
            scylla_version = "master"

        return monitoring_version, scylla_version
    except Exception:  # noqa: BLE001
        return "branch-3.0", "master"


def restore_grafana_dashboards_and_annotations(monitoring_dockers_dir, grafana_docker_port, sct_dashboard_file):
    status = []
    try:
        status.append(
            restore_sct_dashboards(grafana_docker_port=grafana_docker_port, sct_dashboard_file=sct_dashboard_file)
        )
        # annotations are optional: a missing, empty or malformed annotations file must not fail the
        # restore; real upload errors raise and are handled below
        restore_annotations_data(monitoring_dockers_dir, grafana_docker_port=grafana_docker_port)
    except Exception as details:  # noqa: BLE001
        LOGGER.error("Error during uploading sct monitoring data %s", details)
        status.append(False)

    return all(status)


def run_monitoring_stack_containers(monitoring_stack_dir, monitoring_data_dir, scylla_version, tenants_number=1):
    try:
        return start_dockers(monitoring_stack_dir, monitoring_data_dir, scylla_version, tenants_number)
    except Exception as details:  # noqa: BLE001
        LOGGER.error("Dockers are not started. Error: %s", details)
        return {}


@retrying(n=3, sleep_time=20, message="Uploading sct dashboard")
def restore_sct_dashboards(grafana_docker_port, sct_dashboard_file):
    if not sct_dashboard_file:
        LOGGER.warning("There is no dashboard %s. defaults to master dashboard", sct_dashboard_file)
        sct_dashboard_file_name = "scylla-dash-per-server-nemesis.master.json"
        sct_dashboard_file = [Path(__file__).parent.parent.parent / "data_dir" / sct_dashboard_file_name]

    dashboard_url = f"http://localhost:{grafana_docker_port}/api/dashboards/db"
    with open(sct_dashboard_file, encoding="utf-8") as f:
        dashboard_config = json.load(f)
        if dashboard_config.get("dashboard", {}).get("id"):
            dashboard_config["dashboard"]["id"] = None

    try:
        res = requests.post(
            dashboard_url, data=json.dumps(dashboard_config), headers={"Content-Type": "application/json"}
        )

        if res.status_code != 200:
            LOGGER.info("Error uploading dashboard %s. Error message %s", sct_dashboard_file, res.text)
            raise ErrorUploadSCTDashboard(f"Error uploading dashboard {sct_dashboard_file}. Error message {res.text}")
        LOGGER.info("Dashboard %s loaded successfully", sct_dashboard_file)
        return True
    except Exception as details:
        LOGGER.error("Error uploading dashboard %s. Error message %s", sct_dashboard_file, details)
        raise


@retrying(n=3, sleep_time=20, message="Uploading annotations data")
def restore_annotations_data(monitoring_stack_dir, grafana_docker_port):
    annotations_file = os.path.join(monitoring_stack_dir, "sct_monitoring_addons", "annotations.json")

    if not os.path.exists(annotations_file) or os.path.getsize(annotations_file) == 0:
        LOGGER.info("There is no annotations file or it is empty. Skip loading annotations")
        return False

    try:
        with open(annotations_file, encoding="utf-8") as f:
            annotations = json.load(f)
    except json.JSONDecodeError as details:
        LOGGER.warning(
            "Annotations file %s contains invalid JSON: %s. Skip loading annotations", annotations_file, details
        )
        return False

    if not isinstance(annotations, list):
        LOGGER.warning(
            "Annotations file %s does not contain a list of annotations. Skip loading annotations", annotations_file
        )
        return False

    try:
        annotations_url = f"http://localhost:{grafana_docker_port}/api/annotations"
        # Retries are disabled here: the @retrying decorator on this function is the single
        # retry policy for this non-idempotent POST loop, avoiding duplicate annotations.
        session = create_retry_session(retries=0)
        for an in annotations:
            res = session.post(
                annotations_url, data=json.dumps(an), headers={"Content-Type": "application/json"}, timeout=30
            )
            if res.status_code != 200:
                LOGGER.info("Error during uploading annotation %s. Error message %s", an, res.text)
                raise ErrorUploadAnnotations(f"Error during uploading annotation {an}. Error message {res.text}")
        LOGGER.info("Annotations loaded successfully")
        return True
    except Exception as details:
        LOGGER.error("Error during annotations data upload %s", details)
        raise


def get_available_port_candidates(base_port, tenants_number, occupied_ports):
    """Generate list of candidate ports, excluding occupied ones."""
    return [p for p in range(base_port, base_port + tenants_number) if p not in occupied_ports] + [0]


def start_dockers(monitoring_dockers_dir, monitoring_stack_data_dir, scylla_version, tenants_number):
    # pick ports once so retries reuse the same container names and can clean up after themselves
    occupied_ports = get_monitoring_container_ports()

    def pick_port(base_port):
        return get_free_port(ports_to_try=get_available_port_candidates(base_port, tenants_number, occupied_ports))

    graf_port = pick_port(GRAFANA_DOCKER_PORT)
    alert_port = pick_port(ALERT_DOCKER_PORT)
    prom_port = pick_port(PROMETHEUS_DOCKER_PORT)

    _start_dockers_with_ports(
        monitoring_dockers_dir, monitoring_stack_data_dir, scylla_version, graf_port, alert_port, prom_port
    )
    return {"grafana_docker_port": graf_port, "alert_docker_port": alert_port, "prometheus_docker_port": prom_port}


@retrying(n=3, sleep_time=5, message="Start docker containers")
def _start_dockers_with_ports(
    monitoring_dockers_dir, monitoring_stack_data_dir, scylla_version, graf_port, alert_port, prom_port
):
    lr = LocalCmdRunner()

    # force-remove our containers from any previous attempt so retries start clean
    for name in [
        f"{GRAFANA_DOCKER_NAME}-{graf_port}",
        f"{PROMETHEUS_DOCKER_NAME}-{prom_port}",
        f"{ALERT_DOCKER_NAME}-{alert_port}",
    ]:
        lr.run(f"docker rm -f {name}", ignore_status=True, verbose=False)

    lr.run(
        "cd {monitoring_dockers_dir}; ./kill-all.sh -g {graf_port} -m {alert_port} -p {prom_port}".format(**locals()),
        ignore_status=True,
        verbose=False,
    )

    # clear scylla nodes from configuration
    servers_yaml = Path(monitoring_dockers_dir) / "config" / "scylla_servers.yml"
    servers_yaml.write_text("- targets: []")

    # clear SCT scrape configurations
    prom_tmpl_file = Path(monitoring_dockers_dir) / "prometheus" / "prometheus.yml.template"
    templ_yaml = yaml.safe_load(prom_tmpl_file.read_text())

    def remove_sct_metrics(metric):
        return "_metrics" not in metric["job_name"]

    templ_yaml["scrape_configs"] = list(filter(remove_sct_metrics, templ_yaml["scrape_configs"]))
    prom_tmpl_file.write_text(yaml.safe_dump(templ_yaml))

    # clear alert manager configuration to the minimal need config
    alert_manager_config = Path(monitoring_dockers_dir) / "prometheus" / "rule_config.yml"
    alert_manager_config.write_text(
        dedent("""
        global:
          resolve_timeout: 5m
        route:
          receiver: "null"
          group_by:
          - job
          group_wait: 30s
          group_interval: 5m
          repeat_interval: 12h
        receivers:
        - name: "null"
    """).strip()
    )

    cmd = dedent(
        """cd {monitoring_dockers_dir};
            # patch to make podman work for result that don't have https://github.com/scylladb/scylla-monitoring/pull/2149
            sed -i 's/DOCKER_HOST/HOST_ADDRESS/' *.sh

            # allow concurrent restores: don't abort if monitoring containers from another restore exist
            sed -i '/monitoring docker instances.*exist/,/exit 1/ s/exit 1/true/' start-all.sh

            echo "" > UA.sh
            bash -x ./start-all.sh \
            $(grep -q -- --no-renderer ./start-all.sh && echo "--no-renderer")  \
            $(grep -q -- --no-loki ./start-all.sh && echo "--no-loki")  \
            -g {graf_port} -m {alert_port} -p {prom_port} \
            -s {monitoring_dockers_dir}/config/scylla_servers.yml \
            -d {monitoring_stack_data_dir} -v {scylla_version} \
            -b '-storage.tsdb.retention.time=100y' \
            -c 'GF_USERS_DEFAULT_THEME=dark'""".format(**locals())
    )
    res = lr.run(cmd, ignore_status=True)
    if res.ok:
        LOGGER.info("Docker containers for monitoring stack are started")
    else:
        LOGGER.error("Failure to start monitoring stack stderr: %s", res.stderr)
        LOGGER.error("Failure to start monitoring stack stdout: %s", res.stdout)

        raise Exception("fail to start monitoring stack")


def is_docker_available():
    LOGGER.info("Checking that docker is available...")
    result = LocalCmdRunner().run("docker ps", ignore_status=True, verbose=False)
    if result.ok:
        LOGGER.info("Docker is available")
        return True
    else:
        LOGGER.warning("Docker is not available on your computer. Please install docker software before continue")
        return False


def get_monitoring_stack_services(ports):
    return [
        {"service": "Grafana service", "name": GRAFANA_DOCKER_NAME, "port": ports["grafana_docker_port"]},
        {"service": "Prometheus service", "name": PROMETHEUS_DOCKER_NAME, "port": ports["prometheus_docker_port"]},
        {"service": "Alert service", "name": ALERT_DOCKER_NAME, "port": ports["alert_docker_port"]},
    ]


def kill_running_monitoring_stack_services(ports=None):
    dockers_ports = ports or {
        "grafana_docker_port": GRAFANA_DOCKER_PORT,
        "prometheus_docker_port": PROMETHEUS_DOCKER_PORT,
        "alert_docker_port": ALERT_DOCKER_PORT,
    }

    lr = LocalCmdRunner()
    for docker in get_monitoring_stack_services(ports=dockers_ports):
        LOGGER.info("Killing %s", docker["service"])
        lr.run("docker rm -f {name}-{port}".format(name=docker["name"], port=docker["port"]), ignore_status=True)
