# automatically generated by auxilium
import collections
import contextlib
import dataclasses as dc
import enum
import functools
import hashlib
import json
import os
import re
import subprocess
import time
import typing as tp
from pathlib import Path

import requests


def subprocess_run(
    *args: tp.Any, **kwgs: tp.Any
) -> "subprocess.CompletedProcess[bytes]":
    try:
        # pylint: disable=subprocess-run-check
        return subprocess.run(*args, **kwgs)
    except subprocess.CalledProcessError as err:
        if err.stdout:
            print("========>> stdout")
            print(err.stdout.decode("utf-8"))
        if err.stderr:
            print("========>> stderr")
            print(err.stderr.decode("utf-8"))
        raise


@dc.dataclass(frozen=True)
class Job:
    name: str
    stage: str

    @property
    def ident(self) -> str:
        return f"{self.stage}:{self.name}"

    def is_up_to_date(
        self,
        env: tp.Mapping[str, tp.Any],  # pylint: disable=unused-argument
    ) -> bool:
        return True

    def is_included(
        self,
        env: tp.Mapping[str, tp.Any],  # pylint: disable=unused-argument
    ) -> tp.Tuple[bool, str]:
        return True, "by default"

    def script(self) -> None:
        raise NotImplementedError()


@dc.dataclass(frozen=True)
class _TestingJobMixinState:
    log: tp.List[str]


@dc.dataclass(frozen=True)
class TestingJobMixin(_TestingJobMixinState, Job):
    def script(self) -> None:
        if "fail" in self.ident:
            self.log.append(f"failing {self.ident}")
            raise RuntimeError(f"failing {self.ident}")
        self.log.append(self.ident)


@dc.dataclass(frozen=True)
class DependentCheckMixin(Job):  # pylint: disable=abstract-method
    dependent: tp.OrderedDict[str, tp.Any] = dc.field(
        default_factory=collections.OrderedDict
    )

    def is_up_to_date(self, env: tp.Mapping[str, tp.Any]) -> bool:
        reason = None
        for key, val in self.dependent.items():
            reason = getattr(self, f"_check_{key}")(env, val)

            if reason is not None:
                if env.get("verbose", False):
                    print(f"{self.ident:>30} outdated bc {reason}", flush=True)
                return False
        return super().is_up_to_date(env)

    @staticmethod
    def _check_up_to_date(
        env: tp.Mapping[str, tp.Any], val: bool  # pylint: disable=unused-argument
    ) -> tp.Optional[str]:
        if not val:
            return f"up_to_date={val}"
        return None

    @staticmethod
    def _check_files(
        env: tp.Mapping[str, tp.Any], paths: tp.List[Path]
    ) -> tp.Optional[str]:
        for path in paths:
            out = subprocess_run(
                ["git", "diff", "HEAD", "HEAD~1", path],
                cwd=env["cwd"],
                capture_output=True,
                check=True,
            )
            diff = out.stdout.decode("utf-8").strip()
            if diff:
                return f"file-diff in {path}"
        return None


@dc.dataclass(frozen=True)
class TriggerMixin(DependentCheckMixin):  # pylint: disable=abstract-method
    trigger: tp.OrderedDict[str, tp.Any] = dc.field(
        default_factory=collections.OrderedDict
    )

    def is_included(self, env: tp.Mapping[str, tp.Any]) -> tp.Tuple[bool, str]:
        if not self.trigger:
            return super().is_included(env)

        for key, val in self.trigger.items():
            if key in "files":
                continue
            reason = getattr(self, f"_check_{key}")(env, val)
            if reason is not None:
                return True, reason
        return False, ""

    @staticmethod
    def _check_env(
        env: tp.Mapping[str, tp.Any], vals: tp.Dict[str, tp.Any]
    ) -> tp.Optional[str]:
        for key, val in vals.items():
            if env[key] == val:
                return f"{key} == {val}"
        return None

    @staticmethod
    def _check_env_bool(env: tp.Mapping[str, tp.Any], args: str) -> tp.Optional[str]:
        for key in args:
            if env[key]:
                return f"{key} == True"
        return None

    @staticmethod
    def _check_env_and(
        env: tp.Mapping[str, tp.Any], vals: tp.Dict[str, tp.Any]
    ) -> tp.Optional[str]:
        incl = True
        for key, val in vals.items():
            incl &= env[key] == val
        if incl:
            return " & ".join([f"{k} == {v}" for k, v in vals.items()])
        return None

    @staticmethod
    def _check_env_bool_and(
        env: tp.Mapping[str, tp.Any], vals: tp.List[str]
    ) -> tp.Optional[str]:
        incl = True
        for key in vals:
            incl &= env[key]
        if incl:
            return " & ".join([f"{k}" for k in vals]) + " == True"
        return None


class AutoName(enum.Enum):
    def _generate_next_value_(name: str, start, count, last_values) -> str:  # type: ignore  # pylint: disable=no-self-argument
        return name.lower()  # pylint: disable=no-member


class StateEnum(AutoName):
    CREATED = enum.auto()
    PENDING = enum.auto()
    UP_TO_DATE = enum.auto()
    SUCCESS = enum.auto()
    SKIPPED = enum.auto()
    FAILED = enum.auto()
    EXCLUDED = enum.auto()


class State:
    status: StateEnum = StateEnum.CREATED

    def __str__(self) -> str:
        return str(self.status.value)

    def done(self) -> bool:
        return self.status not in [
            StateEnum.CREATED,
            StateEnum.PENDING,
            StateEnum.EXCLUDED,
        ]

    def successful(self) -> bool:
        return self.status in [
            StateEnum.UP_TO_DATE,
            StateEnum.SUCCESS,
            StateEnum.EXCLUDED,
        ]

    def set_done(self, err: tp.Any = None) -> None:
        if err:
            self.status = StateEnum.FAILED
        else:
            self.status = StateEnum.SUCCESS

    @property
    def status_colored(self) -> str:
        if self.status == StateEnum.FAILED:
            col = 1
        elif self.status == StateEnum.SUCCESS:
            col = 2
        elif self.status == StateEnum.SKIPPED:
            col = 3
        elif self.status == StateEnum.UP_TO_DATE:
            col = 6
        else:
            return self.status.value
        return f"\033[1;3{col}m{self.status.value}\033[0m"


@dc.dataclass(frozen=True)
class DoubleLinkedListNode(Job):  # pylint: disable=abstract-method
    state: State = dc.field(default_factory=State)
    children: tp.List["DoubleLinkedListNode"] = dc.field(default_factory=list)
    parents: tp.List["DoubleLinkedListNode"] = dc.field(default_factory=list)

    @property
    def included_parents(self) -> tp.Iterator["DoubleLinkedListNode"]:
        for parent in self.parents:
            if parent.state.status != StateEnum.EXCLUDED:
                yield parent

    @property
    def included_children(self) -> tp.Iterator["DoubleLinkedListNode"]:
        for child in self.children:
            if child.state.status != StateEnum.EXCLUDED:
                yield child

    def __post_init__(self) -> None:
        for parent in self.parents:
            parent.children.append(self)
        for child in self.children:
            child.parents.append(self)

    def add_child(self, child: "DoubleLinkedListNode") -> None:
        self.children.append(child)
        child.parents.append(self)

    def remove_child(self, child: "DoubleLinkedListNode") -> None:
        self.children.remove(child)
        child.parents.remove(self)

    def unlink(self) -> None:
        for parent in reversed(self.parents):
            parent.remove_child(self)
        for child in reversed(self.children):
            self.remove_child(child)

    def is_up_to_date(self, env: tp.Mapping[str, tp.Any]) -> bool:
        for parent in self.parents:
            if parent.state.status == StateEnum.PENDING:
                return False
        return super().is_up_to_date(env)

    def skip_children(self) -> None:
        for child in self.included_children:
            child.state.status = StateEnum.SKIPPED
            child.skip_children()


@dc.dataclass(frozen=True)
class CommonJob(
    TriggerMixin, DependentCheckMixin, DoubleLinkedListNode, Job
):  # pylint: disable=abstract-method
    pass


T = CommonJob  # pylint: disable=invalid-name


@dc.dataclass
class JobManager:
    jobs: tp.OrderedDict[str, T] = dc.field(default_factory=collections.OrderedDict)
    env: tp.Dict[str, tp.Any] = dc.field(default_factory=dict)

    @property
    def included_jobs(self) -> tp.Iterator[T]:
        for job in self.jobs.values():
            if job.state.status != StateEnum.EXCLUDED:
                yield job

    def load_env(self, **kwgs: tp.Any) -> None:
        self.env.update(kwgs)
        self.env.setdefault("cwd", Path.cwd())

    def add_job(self, cls: tp.Type[T], **kwgs: tp.Any) -> T:
        job: T = cls(**kwgs)
        key = job.ident
        if key in self.jobs:
            raise RuntimeError(f"the job {key} is already present!")
        self.jobs[key] = job
        return job

    @property
    def root_jobs(self) -> tp.List[T]:
        return [job for job in self.included_jobs if not list(job.included_parents)]

    def run_if_needed(self, job: T) -> None:
        if job.state.done():
            return
        with self.collapsable_section(job):
            if job.state.status == StateEnum.CREATED:
                self.check_job_necessity(job)

            if job.state.status == StateEnum.PENDING:
                try:
                    job.script()
                    job.state.set_done()
                except (RuntimeError, subprocess.CalledProcessError) as err:
                    if self.env.get("verbose", False):
                        print(err)
                    job.state.set_done(err=err)
                    job.skip_children()

    def check_job_necessity(self, job: T) -> None:
        if job.is_up_to_date(self.env):
            job.state.status = StateEnum.UP_TO_DATE
        else:
            job.state.status = StateEnum.PENDING

    def show_result(self) -> None:
        if self.has_failed_jobs():
            print("\033[1;31mSummary :(\033[0m")
        else:
            print("\033[1;32mSummary :)\033[0m")

        def show(job: T) -> None:
            print(f"{job.ident:>30} {job.state.status_colored}")

        self.recurse(show)

    def check_necessity(self) -> None:
        self.recurse(self.check_job_necessity)

    def run_pipeline(self) -> None:
        self.prune_unincluded()
        self.recurse(self.run_if_needed)

    def recurse(
        self,
        fct: tp.Callable[[T], None],
        root_jobs: tp.Optional[tp.List[T]] = None,
        seen: tp.Tuple[T, ...] = tuple(),
    ) -> None:
        if root_jobs is None:
            root_jobs = self.root_jobs
        for job in root_jobs:
            if job in seen:
                continue
            fct(job)
            seen = seen + (job,)

        todo = []
        for job in root_jobs:
            for child in job.included_children:
                if child not in todo:
                    todo.append(child)
        all_vals = list(self.jobs.values())
        ordered = list(sorted(list(todo), key=all_vals.index))  # type: ignore
        if ordered:
            self.recurse(fct, ordered, seen)  # type: ignore

    def run_single(self, ident: str) -> None:
        for job in self.included_jobs:
            if job.ident != ident:
                job.state.status = StateEnum.EXCLUDED

        self.env["run_single"] = True
        self.run_if_needed(self.jobs[ident])

    def has_failed_jobs(self) -> bool:
        for job in self.included_jobs:
            if not job.state.successful():
                return True
        return False

    def prune_unincluded(self) -> None:
        for job in self.included_jobs:
            incl, reason = job.is_included(self.env)
            if incl:
                if self.env.get("verbose", False):
                    print(f"{job.ident:>30} \033[1;32mincluded\033[0m bc {reason}")
            else:
                job.state.status = StateEnum.EXCLUDED
                if self.env.get("verbose", False):
                    if reason:
                        print(f"{job.ident:>30} \033[1;33momitting\033[0m bc {reason}")
                    else:
                        print(f"{job.ident:>30} \033[1;35mexcluded\033[0m")

    def add_docker_build_job(
        self,
        name: str,
        *,
        stage: str = "build",
        service: tp.Optional[str] = None,
        files: tp.Optional[tp.List[str]] = None,
        parents: tp.Optional[tp.List[T]] = None,
        images: tp.Optional[tp.List[str]] = None,
        trigger: tp.Optional[tp.Mapping[str, tp.Any]] = None,
        branch_match: tp.Optional[tp.List[tp.Tuple[str, str, str]]] = None,
    ) -> T:
        slug = self.env["slug"]
        service = service or f"{slug}-{name}" if name.split("-", 1)[0] != slug else name
        files = files or []
        files = [f"devops/docker/{service}.dockerfile"] + files
        job = self.add_job(
            DockerBuild,
            name=name,
            stage=stage,
            service=service,
            parents=parents or [],
            images=images or [],
            trigger=trigger or collections.OrderedDict([("env_bool", ["push"])]),
            branch_match=branch_match or [],
        )
        job.trigger["files"] = files
        return job

    def add_docker_run_job(
        self,
        name: str,
        *,
        parents: tp.List[T],
        stage: str = "run",
        services: tp.Optional[tp.List[str]] = None,
        additional_services: tp.Optional[tp.List[str]] = None,
        trigger: tp.Optional[tp.Mapping[str, tp.Any]] = None,
        dependent: tp.Optional[tp.Mapping[str, tp.Any]] = None,
    ) -> T:
        slug = self.env["slug"]
        services = services or [f"{slug}-{name}"]

        additional_services = additional_services or []
        for service in additional_services:
            assert service not in services
        services += additional_services
        job = self.add_job(
            DockerRun,
            name=name,
            stage=stage,
            services=services,
            parents=parents,
            trigger=trigger or collections.OrderedDict([("env_bool", ["push"])]),
            dependent=dependent or collections.OrderedDict([("up_to_date", False)]),
        )
        return job

    def add_spez_run_job(
        self,
        name: str,
        *,
        stage: str,
        parents: tp.List[T],
        trigger: tp.Optional[tp.Mapping[str, tp.Any]] = None,
        dependent: tp.Optional[tp.Mapping[str, tp.Any]] = None,
    ) -> T:

        job = self.add_job(
            SpezRun,
            name=name,
            stage=stage,
            parents=parents,
            trigger=trigger or collections.OrderedDict([("env_bool", ["push"])]),
            dependent=dependent or collections.OrderedDict([("up_to_date", False)]),
        )
        return job

    def add_testing_job(self, name: str, **kwgs: tp.Any) -> T:
        job = self.add_job(TestingJob, name=name, log=self.env["log"], **kwgs)
        return job

    def name2build(self, name: str) -> T:
        return self._name2x(name, lambda x: isinstance(x, DockerBuild))

    def name2run(self, name: str) -> T:
        return self._name2x(name, lambda x: not isinstance(x, DockerBuild))

    def _name2x(self, name: str, criteria: tp.Callable[[T], bool]) -> T:
        match = []
        for job in self.jobs.values():
            if job.name == name and criteria(job):
                match.append(job)
        if len(match) != 1:
            raise RuntimeError(f"match not unique, found {match}!")
        return match[0]

    @contextlib.contextmanager
    def collapsable_section(self, job: T) -> tp.Iterator[None]:
        title = f"{job.ident}"
        sec = f'section_{job.ident.replace(":", "_")}'
        collapsed = "true"
        if self.env.get("run_single", False):
            collapsed = "false"
        print(
            f"\033[0Ksection_start:{int(time.time())}:{sec}[collapsed={collapsed}]\r\033[0K╔═ {title}",
            flush=True,
        )
        yield
        print(
            f"\033[0Ksection_end:{int(time.time())}:{sec}\r\033[0K╚═ {job.state.status_colored}",
            flush=True,
        )


@dc.dataclass(frozen=True)
class DockerShared(CommonJob):
    # Run local registry:
    # docker run -d -p 5000:5000 --name registry registry:2
    # docker container stop registry && docker container rm -v registry
    @property
    def registry_host(self) -> str:
        return os.environ.get("CI_REGISTRY", "localhost:5000")

    @property
    def build_deps(self) -> tp.Iterator["DockerBuildMixin"]:
        for parent in self.parents:
            if isinstance(parent, DockerBuildMixin):
                yield parent

    def script(self) -> None:
        # get dependencies
        for job in self.build_deps:
            remote_tag = job.remote_tag
            cmd = ["docker", "image", "inspect", remote_tag]
            res = subprocess_run(cmd, check=False, capture_output=True)
            if res.returncode != 0:
                cmd = ["docker", "pull", remote_tag]
                subprocess_run(cmd, check=True, capture_output=True)
                print(f"pulled '{remote_tag}'", flush=True)

            # I tag every time as it allows different dependencies to
            # map to the same service name without raising issues
            # (useful for local development)
            cmd = ["docker", "image", "tag", remote_tag, job.service]

            subprocess_run(cmd, check=True, capture_output=True)

            print(f"tagged '{remote_tag}' -> '{job.service}'", flush=True)

    def _get_tag_parts(self) -> tp.Iterator[tp.Union[bytes, str]]:
        for job in self.build_deps:
            yield job.tag


@dc.dataclass(frozen=True)
class _DockerBuildMixinState:
    service: str


# try to download for caching (or reuse if already there)
def check_and_pull_if_not_existent(remote_tag: str, local_tag: str) -> None:
    cmd = ["docker", "image", "inspect", remote_tag]
    res = subprocess_run(cmd, check=False, capture_output=True)
    if res.returncode != 0:
        cmd = ["docker", "pull", remote_tag]
        res = subprocess_run(cmd, check=False)
    if res.returncode == 0:
        cmd = ["docker", "image", "tag", remote_tag, local_tag]
        subprocess_run(cmd, check=True)


def upload_to_remote(local_tag: str, remote_tag: str) -> None:
    cmd = ["docker", "image", "tag", local_tag, remote_tag]
    subprocess_run(cmd, check=True, capture_output=True)

    cmd = ["docker", "push", remote_tag]
    subprocess_run(cmd, check=True, capture_output=True)


@dc.dataclass(frozen=True)
class DockerBuildMixin(DockerShared, _DockerBuildMixinState, Job):
    images: tp.List[str] = dc.field(default_factory=list)

    def script(self) -> None:
        super().script()
        remote_tag = self.remote_tag

        check_and_pull_if_not_existent(remote_tag, self.service)

        # download other images which are not a direct dependency (cross-repo)
        for image_tag in self.images:
            image_remote_tag = f"{self.registry_host}/{image_tag}"
            check_and_pull_if_not_existent(image_remote_tag, image_tag)

        # build
        cmd = [
            "docker",
            "compose",
            "-f",
            "devops/docker/compose.yml",
            "build",
            self.service,
        ]
        subprocess_run(cmd, check=True)

        # upload
        upload_to_remote(self.service, remote_tag)
        print(f"uploaded {self.tag} to {remote_tag}", flush=True)

    @functools.cached_property
    def tag(self) -> str:
        hasher = hashlib.md5()

        for part in self._get_tag_parts():
            if isinstance(part, str):
                hasher.update(part.encode("utf8"))
            else:
                hasher.update(part)

        tag = hasher.hexdigest()[:16]
        return f"aux-{tag}"

    def _get_tag_parts(self) -> tp.Iterator[tp.Union[bytes, str]]:
        yield from super()._get_tag_parts()
        val = self.trigger["files"]
        for file in val:
            with open(file, "rb") as f:
                yield f.read()

        # we assume the image tag is not moving (i.e. no latest)
        yield from self.images

    @functools.cached_property
    def remote_tag(self) -> str:
        image = self.get_image_name()
        return f"{self.registry_host}/{image}:{self.tag}"

    def get_image_name(self) -> str:
        if "CI_PROJECT_NAMESPACE" in os.environ:
            group = os.environ["CI_PROJECT_NAMESPACE"]
            repo = os.environ["CI_PROJECT_NAME"]
            return f"{group}/{repo}/{self.service}".lower()
        return f"{self.service}".lower()

    @functools.cached_property
    def registry_api_token(self) -> str:
        # https://www.pimwiddershoven.nl/entry/request-an-api-bearer-token-from-gitlab-jwt-authentication-to-control-your-private-docker-registry
        # curl --user 'user:pw' 'https://gitlab.com/jwt/auth?client_id=docker&offline_token=true&service=container_registry&scope=repository:administratum/auxilium/adaux-pytest-39:pull,push'
        # curl -H 'Authorization: Bearer token' https://gitlab.com:5050/v2/administratum/auxilium/adaux-pytest-39/tags/list
        image = self.get_image_name()
        server = os.environ["CI_SERVER_URL"]
        url = f"{server}/jwt/auth?client_id=docker&offline_token=true&service=container_registry&scope=repository:{image}:pull,push"
        req = requests.get(
            url,
            auth=(os.environ["CI_REGISTRY_USER"], os.environ["CI_JOB_TOKEN"]),
            timeout=10,
        )
        return str(json.loads(req.text)["token"])

    def is_up_to_date(self, env: tp.Mapping[str, tp.Any]) -> bool:
        protocol = "http"
        headers = {}

        if self.registry_host != "localhost:5000":
            protocol = "https"
            headers["Authorization"] = f"Bearer {self.registry_api_token}"

        # https://gitlab.com/gitlab-org/gitlab/-/issues/19470
        url = f"{protocol}://{self.registry_host}/v2/{self.get_image_name()}/tags/list"

        try:
            req = requests.get(url, headers=headers, timeout=10)
        except requests.exceptions.ConnectionError as err:
            raise RuntimeError(
                f"are you sure the registry {self.registry_host} is reachable?\n"
                "use: docker run -d -p 5000:5000 --name registry registry:2"
            ) from err

        if req.status_code != 200:
            return False
        tags = json.loads(req.text)["tags"] or []
        if self.tag in tags:
            return super().is_up_to_date(env)
        return False

    def is_included(self, env: tp.Mapping[str, tp.Any]) -> tp.Tuple[bool, str]:
        up_to_date = self.is_up_to_date(env)
        incl, reason = super().is_included(env)
        if incl and up_to_date:
            return False, f"tag={self.tag} exists"
        return incl, reason


@dc.dataclass(frozen=True)
class ConfigAccess:
    @classmethod
    def get_config_and_path(cls) -> tp.Tuple[tp.Any, Path]:
        path = Path(__file__).resolve().parent.parent.parent
        config: tp.Dict[str, tp.Dict[str, str]] = dict(project={})
        # poormans yaml-parser as I dont want to install ruamel.yaml due to gcc req.
        with open(path / "auxilium.yml", encoding="utf-8") as f:
            within_project_lines = False
            for line in f.readlines():
                if "project" in line:
                    within_project_lines = True
                elif not line.startswith(" "):
                    within_project_lines = False
                elif within_project_lines:
                    line = line.strip()
                    for key in ["name", "slug", "source_dir"]:
                        if key + ":" in line:
                            key, val = line.split(":", 1)
                            config["project"][key] = val.strip()
        return config, path


@dc.dataclass(frozen=True)
class _DockerBuildBranchMatchMixinState(_DockerBuildMixinState):
    branch_match: tp.List[tp.Tuple[str, str, str, tp.List[str]]]


@dc.dataclass(frozen=True)
class DockerBuildBranchMatchMixin(
    ConfigAccess, DockerBuildMixin, _DockerBuildBranchMatchMixinState
):
    def _get_tag_parts(self) -> tp.Iterator[tp.Union[bytes, str]]:
        yield from super()._get_tag_parts()

        branch = os.environ.get("CI_COMMIT_BRANCH", "") + os.environ.get(
            "CI_MERGE_REQUEST_SOURCE_BRANCH_NAME", ""
        )
        for pkg, var, url, skip in self.branch_match:
            assert branch
            if branch in skip:
                print(f"skipping matching branch '{branch}' for '{pkg}'")
                continue
            secret = re.search(r"\$(\w+)@", url).group(1)  # type: ignore
            url = url.replace(f"${secret}", os.environ[secret])
            # figure out if matching branch exists
            exists, sha = self._matching_branch_exist(url, branch)
            if exists:
                print(f"found matching branch '{branch}' for '{pkg}'")
                yield sha
                os.environ[var] = f"{pkg}@git+{url}@{branch}"

    @classmethod
    def _matching_branch_exist(cls, url: str, branch: str) -> tp.Tuple[bool, str]:
        cmd = f"git ls-remote {url} {branch}".split(" ")
        res = subprocess_run(cmd, check=False, capture_output=True)
        if res.returncode != 0:
            raise RuntimeError(res.stderr.decode("utf8"))
        parts = res.stdout.decode("utf8").split()
        if parts:
            sha, refs = parts
            assert branch in refs
            return True, sha
        return False, ""


@dc.dataclass(frozen=True)
class _DockerRunMixinState:
    services: tp.List[str]


@dc.dataclass(frozen=True)
class DockerRunMixin(DockerShared, _DockerRunMixinState, Job):
    def script(self) -> None:
        super().script()

        cmd = [
            "docker",
            "compose",
            "-f",
            "devops/docker/compose.yml",
            "up",
            "--no-build",
            "--abort-on-container-exit",
            *self.services,
        ]
        subprocess_run(cmd, check=True)


@dc.dataclass(frozen=True)
class DockerBuild(
    DockerBuildBranchMatchMixin, CommonJob, _DockerBuildBranchMatchMixinState, Job
):
    pass


@dc.dataclass(frozen=True)
class DockerRun(DockerRunMixin, CommonJob, _DockerRunMixinState, Job):
    pass


@dc.dataclass(frozen=True)
class SpezRun(ConfigAccess, CommonJob):
    def script(self) -> None:
        if self.name == "check-release-notes":
            self.check_release_notes()
        elif self.name == "gitlab-release":
            self.release_gitlab()
        elif self.name == "release-tag":
            self.release_tag()
        elif self.name == "develop-tag":
            self.develop_tag()
        else:
            raise NotImplementedError(self.name)

    def check_release_notes(self) -> None:
        version, release_notes, _ = self.get_version_release_notes_slug()
        # check that version has a note
        if version not in release_notes:
            raise RuntimeError(
                f"version {version} is not in release notes, please add!"
            )
        # check that version was not already released
        out = subprocess_run(
            ["git", "ls-remote", "--tags"], check=True, capture_output=True
        )
        already_released = list(re.findall(r"tags/([\d.]+)\n", out.stdout.decode()))
        if version in already_released:
            raise RuntimeError(
                f"version {version} was already released and cannot be released again!"
            )

        print(f"version {version} has description '{release_notes[version]}'")

    def release_gitlab(self) -> None:
        version, release_notes, slug = self.get_version_release_notes_slug()
        description = release_notes[version]
        os.environ["RELEASE_TAG"] = version
        os.environ["RELEASE_DESCRIPTION"] = description
        DockerRun("gitlab-release", self.stage, [f"{slug}-gitlab-release"]).script()

    def release_tag(self) -> None:
        version, _, _ = self.get_version_release_notes_slug()
        self._tag_image_of_parent_job(version, "latest")

    def develop_tag(self) -> None:
        self._tag_image_of_parent_job("develop")

    def _tag_image_of_parent_job(self, *tags: str) -> None:
        if len(self.parents) != 1:
            raise RuntimeError(
                f"tag job for {tags} should only have 1 parent, not {len(self.parents)}!"
            )
        par = self.parents[0]
        assert isinstance(par, DockerBuild)
        remote_tag = par.remote_tag
        local_tag = par.service
        check_and_pull_if_not_existent(remote_tag, local_tag)
        for release_version in tags:
            release_tag = remote_tag.replace(par.tag, release_version)
            upload_to_remote(local_tag, release_tag)

    @classmethod
    def get_version_release_notes_slug(cls) -> tp.Tuple[str, tp.Dict[str, str], str]:
        config, path = cls.get_config_and_path()
        version = cls.get_version(path, config)
        release_notes = cls.get_release_notes(path, config)
        slug = config["project"]["slug"]
        return version, release_notes, slug

    @classmethod
    def get_version(cls, path: Path, config: tp.Mapping[str, tp.Any]) -> str:
        proj_name = config["project"]["name"]
        source_dir = config["project"].get("source_dir", "source")

        # I cannot access module_dir here, as we dont have the extra context manager
        init = path / source_dir / proj_name.replace(".", "/") / "__init__.py"
        assert init.exists()
        with init.open("r", encoding="utf8") as f:
            for line in f.readlines():
                if "__version__" in line:
                    version = line.strip().split('"', 2)[1]
                    return str(version)
        raise NotImplementedError("version not found!")

    @classmethod
    def get_release_notes(
        cls, path: Path, config: tp.Mapping[str, tp.Any]
    ) -> tp.Dict[str, str]:
        source_dir = config["project"].get("source_dir", "source")
        release_notes_file = path / source_dir / "release-notes.txt"
        if not release_notes_file.exists():
            raise RuntimeError(f"{release_notes_file} does not exist!")
        release_notes = {}
        with release_notes_file.open("r", encoding="utf-8") as f:
            for line in f.readlines():
                version, description = line.strip().split(" ", 1)
                release_notes[version] = description
        return release_notes


@dc.dataclass(frozen=True)
class TestingJob(CommonJob, TestingJobMixin, _TestingJobMixinState, Job):
    pass
