# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See LICENSE for more details.
#
# Copyright (c) 2021 ScyllaDB

import os
import logging
from pathlib import Path
import re
import xml.etree.ElementTree as ET

import jenkins
import yaml

from sdcm.wait import wait_for
from sdcm.utils.common import get_sct_root_path
from sdcm.keystore import KeyStore

DIR_TEMPLATE = Path(__file__).parent.joinpath("folder-template.xml").read_text(encoding="utf-8")
JOB_TEMPLATE = Path(__file__).parent.joinpath("template.xml").read_text(encoding="utf-8")
LOGGER = logging.getLogger(__name__)

_TEST_CONFIG_SINGLE_RE = re.compile(r"test_config:\s*['\"]([^'\"]+\.yaml)")
_TEST_CONFIG_LIST_RE = re.compile(r'test_config:\s*"""?\[([^\]]+)\]')


class JenkinsPipelines:
    def __init__(self, base_job_dir, sct_branch_name, sct_repo, username=None, password=None):
        if not username and not password:
            creds = KeyStore().get_json("jenkins.json")
            username, password = creds.get("username"), creds.get("password")

        self.jenkins = jenkins.Jenkins("https://jenkins.scylladb.com", username=username, password=password)
        self.base_sct_dir = Path(__file__).parent.parent.parent
        self.base_job_dir = base_job_dir
        self.sct_branch_name = sct_branch_name
        self.sct_repo = sct_repo

    def reconfig_job(self, new_path, dir_xml_data):
        self.jenkins.reconfig_job(new_path, dir_xml_data)

    def update_pipeline(self, new_path, xml_data):
        old_xml_data = self.jenkins.get_job_config(new_path)
        old_configuration = ET.fromstring(old_xml_data)
        new_configuration = ET.fromstring(xml_data)
        for actions in old_configuration.findall("./actions"):
            new_configuration.append(actions)
        for properties in old_configuration.findall("./properties"):
            new_configuration.append(properties)
        self.jenkins.reconfig_job(new_path, ET.tostring(new_configuration).decode("utf-8"))

    def create_directory(self, name: Path | str, display_name: str, description: str = ""):
        try:
            dir_xml_data = DIR_TEMPLATE % dict(sct_display_name=display_name, sct_description=description)
            new_path = str(Path(self.base_job_dir) / name)

            if self.jenkins.job_exists(new_path):
                if str(name) in ["scylla-master", "scylla-enterprise"]:
                    # we skip those folder, since they aren't created by SCT,
                    # and we don't want to risk overriding anything configured in them
                    return
                LOGGER.info("reconfig folder [%s]", new_path)
                self.reconfig_job(new_path, dir_xml_data)
            else:
                LOGGER.info("creating folder [%s]", new_path)
                self.jenkins.create_job(new_path, dir_xml_data)
        except jenkins.JenkinsException as ex:
            self._log_jenkins_exception(ex)

    def create_freestyle_job(
        self, xml_temple: Path | str, group_name: Path | str, job_name: str = None, template_context: dict = None
    ):
        xml_temple = Path(xml_temple)
        base_name = job_name or xml_temple.stem

        context = template_context if template_context else {}
        context = {"sct_branch": self.sct_branch_name, "sct_repo": self.sct_repo, **context}
        base_name = base_name % context

        if group_name and self.base_job_dir:
            group_name = "/" + str(group_name)

        xml_data = xml_temple.read_text(encoding="utf-8") % context
        job_name = f"{self.base_job_dir}{group_name}/{base_name}"
        try:
            if self.jenkins.job_exists(job_name):
                LOGGER.info("%s is used to reconfig job", job_name)
                self.reconfig_job(job_name, xml_data)
            else:
                LOGGER.info("%s is used to create job", job_name)
                self.jenkins.create_job(job_name, xml_data)
        except jenkins.JenkinsException as ex:
            self._log_jenkins_exception(ex)

    def create_pipeline_job(
        self,
        jenkins_file: Path | str,
        group_name: Path | str,
        job_name: str = None,
        job_name_suffix="-test",
        defines: dict = None,
    ):
        jenkins_file = Path(jenkins_file)
        base_name = job_name or jenkins_file.stem
        sct_jenkinsfile = jenkins_file.relative_to(get_sct_root_path())
        text_description = self.get_job_description(jenkins_file)
        if defines:
            description = f"{text_description}\n\n### JobDefinitions\n{yaml.safe_dump(defines)}"
        else:
            description = str(sct_jenkinsfile)

        metadata_block = self.get_metadata_description(jenkins_file)
        if metadata_block:
            description = f"{description}\n\n{metadata_block}"

        xml_data = JOB_TEMPLATE % dict(
            sct_display_name=f"{base_name}{job_name_suffix}",
            sct_description=description,
            sct_repo=self.sct_repo,
            sct_branch_name=self.sct_branch_name,
            sct_jenkinsfile=sct_jenkinsfile,
        )
        xml_data = self._inject_test_metadata_xml(xml_data, jenkins_file)
        if group_name and self.base_job_dir:
            group_name = "/" + str(group_name)
        _job_name = f"{self.base_job_dir}{group_name}/{base_name}{job_name_suffix}"
        try:
            if self.jenkins.job_exists(_job_name):
                LOGGER.info("%s is used to reconfig job", _job_name)

                self.update_pipeline(_job_name, xml_data)
            else:
                LOGGER.info("%s is used to create job", _job_name)
                self.jenkins.create_job(_job_name, xml_data)
                self.build_job_and_wait_completion(_job_name)
        except jenkins.JenkinsException as ex:
            self._log_jenkins_exception(ex)

    def build_job_and_wait_completion(self, name):
        """start job first time

        Need to start job first time
        so jenkins will read all job parameters
        Job started one by one, to avoid situation
        when all jenkins resources will be allocated
        """
        LOGGER.info("Start first build %s", name)
        job_id = self.jenkins.build_job(name)

        # wait while worker will be found
        def check_job_is_started(job_id):
            return self.jenkins.get_queue_item(job_id).get("executable")

        wait_for(check_job_is_started, step=5, text="Job is starting", timeout=120, throw_exc=True, job_id=job_id)

        LOGGER.info("First build finished")

    @staticmethod
    def _log_jenkins_exception(exc):
        if "already exists" in str(exc):
            LOGGER.info(exc)
        else:
            LOGGER.error(exc)

    def load_defines(self, root: Path) -> dict[str, str]:
        try:
            content = (root / "_folder_definitions.yaml").open()
        except FileNotFoundError:
            return {}
        defines = yaml.safe_load(content)
        return defines

    @staticmethod
    def locate_job_overrides(job_name: str, overrides: dict[str, str]) -> dict[str, str]:
        if full_match := overrides.get(job_name):
            return full_match

        for key in overrides:
            if re.search(re.compile(key, re.IGNORECASE), job_name):
                return overrides.get(key, {})

        return {}

    @staticmethod
    def get_job_description(job_path: Path) -> str:
        if not job_path.exists():
            return ""

        file = job_path.open()

        description_lines: list[str] = []
        reading = False
        for line in file.readlines():
            if re.match(r"\s*/\*\*\s*jobDescription", line):
                reading = True
                continue
            if re.match(r"\s*\*/", line):
                reading = False
                break

            if reading:
                description_lines.append(line)

        description = "\n".join(line.strip() for line in description_lines)

        return description

    def process_symlinks(self, local_path: Path, jenkins_path: str | Path, job_name_suffix: str, defines: dict):
        """Process _symlinks.yaml to create Jenkins jobs that reference pipeline files from other locations.

        This avoids duplicating .jenkinsfile files when the same pipeline needs to appear
        in multiple Jenkins folders (e.g., scylla-doctor needing copies of artifact tests).

        The _symlinks.yaml format:

            symlinks:
              - name: "artifacts-docker"
                target: "jenkins-pipelines/oss/artifacts/artifacts-docker.jenkinsfile"
              - name: "artifacts-ami"
                target: "jenkins-pipelines/oss/artifacts/artifacts-ami.jenkinsfile"
                job_name_suffix: "-test"  # optional, overrides the default suffix

        Each entry creates a Jenkins pipeline job in the current directory that points
        to the target .jenkinsfile's scriptPath.
        """
        symlinks_file = local_path / "_symlinks.yaml"
        if not symlinks_file.exists():
            return

        try:
            with open(symlinks_file, encoding="utf-8") as fobj:
                data = yaml.safe_load(fobj)
        except (yaml.YAMLError, OSError) as exc:
            LOGGER.error("Failed to load %s: %s", symlinks_file, exc)
            return

        if not data or "symlinks" not in data:
            return

        for entry in data["symlinks"]:
            name = entry.get("name")
            target = entry.get("target")
            if not name or not target:
                LOGGER.warning("Invalid symlink entry in %s: missing 'name' or 'target': %s", symlinks_file, entry)
                continue

            target_path = self.base_sct_dir / target
            if not target_path.exists():
                LOGGER.error("Symlink target does not exist: %s (referenced from %s)", target_path, symlinks_file)
                continue

            entry_suffix = entry.get("job_name_suffix", job_name_suffix)
            LOGGER.info("Creating symlink job '%s%s' -> %s", name, entry_suffix, target)
            self.create_pipeline_job(
                target_path,
                group_name=jenkins_path,
                job_name=name,
                job_name_suffix=entry_suffix,
                defines=defines,
            )

    def disable_job(self, job_name: str, reason: str = "") -> bool:
        """Disable a Jenkins job by setting <disabled>true</disabled> in its config.

        Args:
            job_name: Full Jenkins job path (relative to base_job_dir).
            reason: Optional reason prepended to the job description.

        Returns:
            True if the job was disabled, False if it doesn't exist or failed.
        """
        full_path = f"{self.base_job_dir}/{job_name}" if not job_name.startswith(self.base_job_dir) else job_name
        try:
            if not self.jenkins.job_exists(full_path):
                LOGGER.warning("Job '%s' does not exist, skipping disable", full_path)
                return False

            xml_data = self.jenkins.get_job_config(full_path)
            config = ET.fromstring(xml_data)

            disabled_elem = config.find("disabled")
            if disabled_elem is not None:
                disabled_elem.text = "true"
            else:
                disabled_elem = ET.SubElement(config, "disabled")
                disabled_elem.text = "true"

            if reason:
                desc_elem = config.find("description")
                if desc_elem is None:
                    desc_elem = ET.SubElement(config, "description")
                prefix = f"[DEPRECATED] {reason}\n\n"
                if desc_elem.text and not desc_elem.text.startswith("[DEPRECATED]"):
                    desc_elem.text = prefix + desc_elem.text
                elif not desc_elem.text:
                    desc_elem.text = prefix.strip()

            self.jenkins.reconfig_job(full_path, ET.tostring(config).decode("utf-8"))
            LOGGER.info("Disabled job: %s (reason: %s)", full_path, reason or "none")
            return True
        except jenkins.JenkinsException as ex:
            self._log_jenkins_exception(ex)
            return False

    def process_deprecated_jobs(self, local_path: str | Path):
        """Process _deprecated_jobs.yaml files in the directory tree.

        Each _deprecated_jobs.yaml contains a list of jobs to disable:

            jobs:
              - name: "sct_triggers/tier1-aws-custom-time-trigger"
                reason: "Replaced by tier1-trigger using triggerMatrixPipeline"
              - name: "sct_triggers/sanity-aws-trigger"
                reason: "Replaced by sanity-trigger using triggerMatrixPipeline"

        Args:
            local_path: Root directory to walk for _deprecated_jobs.yaml files.
        """
        local_path = Path(local_path)
        for root, _, files in os.walk(local_path):
            if "_deprecated_jobs.yaml" not in files:
                continue

            deprecated_file = Path(root) / "_deprecated_jobs.yaml"
            try:
                with open(deprecated_file, encoding="utf-8") as fobj:
                    data = yaml.safe_load(fobj)
            except (yaml.YAMLError, OSError) as exc:
                LOGGER.error("Failed to load %s: %s", deprecated_file, exc)
                continue

            if not data or "jobs" not in data:
                continue

            jenkins_path = Path(root).relative_to(local_path)
            if str(jenkins_path) == ".":
                jenkins_path = Path("")

            for entry in data["jobs"]:
                if isinstance(entry, str):
                    job_name = entry
                    reason = ""
                elif isinstance(entry, dict):
                    job_name = entry.get("name", "")
                    reason = entry.get("reason", "")
                else:
                    LOGGER.warning("Invalid entry in %s: %s", deprecated_file, entry)
                    continue

                if not job_name:
                    continue

                full_job_name = str(jenkins_path / job_name) if str(jenkins_path) else job_name
                self.disable_job(full_job_name, reason=reason)

    @staticmethod
    def _extract_test_config_path(jenkins_file: Path) -> str | None:
        """Extract the first test_config YAML path from a Jenkinsfile."""
        content = jenkins_file.read_text(encoding="utf-8")
        match = _TEST_CONFIG_SINGLE_RE.search(content)
        if match:
            return match.group(1)
        list_match = _TEST_CONFIG_LIST_RE.search(content)
        if list_match:
            first = list_match.group(1).split(",")[0].strip().strip("'\"")
            if first.endswith(".yaml"):
                return first
        return None

    @classmethod
    def _load_test_metadata(cls, jenkins_file: Path) -> dict | None:
        """Load test_metadata dict from the test-case YAML referenced by a Jenkinsfile."""
        config_path = cls._extract_test_config_path(jenkins_file)
        if not config_path:
            return None
        yaml_path = get_sct_root_path() / config_path
        if not yaml_path.exists():
            return None
        with yaml_path.open() as fh:
            config = yaml.safe_load(fh)
        if not config or "test_metadata" not in config:
            return None
        meta = config["test_metadata"]
        if not isinstance(meta, dict):
            return None
        return meta

    @classmethod
    def get_metadata_description(cls, jenkins_file: Path) -> str:
        """Read test_metadata from the test-case YAML and format for job description."""
        try:
            meta = cls._load_test_metadata(jenkins_file)
            if not meta:
                return ""
            description = meta.get("description", "")
            tier = meta.get("tier", "n/a")
            test_type = meta.get("test_type", "n/a")
            duration_class = meta.get("duration_class", "n/a")
            backends = meta.get("supported_backends", [])
            lines = []
            if description:
                lines.append(description.strip())
                lines.append("")
            lines.append("### TestMetadata")
            lines.append(f"tier: {tier}")
            lines.append(f"test_type: {test_type}")
            lines.append(f"duration_class: {duration_class}")
            lines.append(f"supported_backends: {backends}")
            return "\n".join(lines)
        except (OSError, KeyError, ValueError, TypeError, yaml.YAMLError):
            LOGGER.debug("Could not read test_metadata from %s", jenkins_file, exc_info=True)
            return ""

    def _inject_test_metadata_xml(self, xml_data: str, jenkins_file: Path) -> str:
        try:
            meta = self._load_test_metadata(jenkins_file)
            if not meta:
                return xml_data
            root = ET.fromstring(xml_data)
            for existing in root.findall("testMetadata"):
                root.remove(existing)
            elem = ET.SubElement(root, "testMetadata")
            for key in ("tier", "test_type", "duration_class"):
                if key in meta:
                    ET.SubElement(elem, key).text = str(meta[key])
            if "supported_backends" in meta:
                backends_elem = ET.SubElement(elem, "supported_backends")
                for backend in meta["supported_backends"]:
                    ET.SubElement(backends_elem, "backend").text = str(backend)
            return ET.tostring(root, encoding="unicode", xml_declaration=True)
        except (OSError, KeyError, ValueError, TypeError):
            LOGGER.debug("Could not inject testMetadata XML from %s", jenkins_file, exc_info=True)
            return xml_data

    def create_job_tree(
        self,
        local_path: str | Path,
        create_freestyle_jobs: bool = True,
        create_pipelines_jobs: bool = True,
        template_context: dict | None = None,
        job_name_suffix: str = "-test",
    ):
        # Process deprecated jobs first so they get disabled before any new jobs are created
        self.process_deprecated_jobs(local_path)

        for root, _, job_files in os.walk(local_path):
            jenkins_path = Path(root).relative_to(local_path)
            defines = self.load_defines(Path(root))
            job_overrides = defines.pop("overrides", {})

            # get display names, if available
            display_name = defines.pop("folder-name", None) or jenkins_path.name
            folder_description = defines.pop("folder-description", "")
            if "_display_name" in job_files:
                display_name = (Path(root) / "_display_name").read_text().strip()

            if str(jenkins_path) == ".":
                jenkins_path = ""
                display_name = self.base_job_dir.split("/")[-1]

            if jenkins_path and display_name:
                self.create_directory(jenkins_path, display_name=display_name, description=folder_description)

            for job_file in job_files:
                job_file = Path(root) / job_file  # noqa: PLW2901
                if (job_file.suffix == ".jenkinsfile") and create_pipelines_jobs:
                    suffix = "" if job_file.stem.endswith("-trigger") else job_name_suffix
                    self.create_pipeline_job(
                        job_file,
                        group_name=jenkins_path,
                        job_name_suffix=suffix,
                        defines={**defines, **self.locate_job_overrides(job_file.stem, job_overrides)},
                    )
                if (job_file.suffix == ".xml") and create_freestyle_jobs:
                    self.create_freestyle_job(job_file, group_name=jenkins_path, template_context=template_context)

            if create_pipelines_jobs:
                self.process_symlinks(Path(root), jenkins_path, job_name_suffix, defines)
