# Copyright 2026 Canonical Ltd.
# SPDX-License-Identifier: GPL-3.0-only

import distro_info
import enum
import os
import tarfile

from debian import (
    deb822,
    debian_support,
    changelog,
)
from launchpadlib.launchpad import Launchpad
from pathlib import Path
from typing import Any


class LintResult(enum.Enum):
    """
    The possible results of a lint check. When a LintException is raised,
    its result attribute will be set with one of SKIP, WARN, ERROR, FAIL.
    """

    OK = enum.auto()
    SKIP = enum.auto()
    WARN = enum.auto()
    ERROR = enum.auto()
    FAIL = enum.auto()


class LintException(Exception):
    """
    This exception is raised when a linter calls lint_fail, lint_warn, or lint_skip.
    Callers of linters should handle this exception to determine why a specific linter
    failed.
    """

    def __init__(self, reason: str, result: LintResult = LintResult.FAIL):
        self.result = result
        self.reason = reason

        super().__init__(reason)


class MissingContextException(Exception):
    """
    This exception is raised when a linter tries to use context (e.g. changelog or
    changes file), but it is not initialized for this Context instance.
    """

    pass


class InconsistentContextException(Exception):
    """
    This exception is raised when one or more context sources have inconsistent
    data for the same information.
    """

    def __init__(self, what: str, from_changes: Any, from_changelog: Any):
        self.what = what
        self.from_changes = from_changes
        self.from_changelog = from_changelog

        super().__init__(
            f"conflicting data for {what}: changes data ({from_changes}) does not "
            f"match changelog data ({from_changelog})"
        )


class Context:
    """
    A class to encapsulate the context of a source package, or package upload
    for a linter. Instances of Context are passed to linters.
    """

    def __init__(
        self,
        changes: str | deb822.Changes | None = None,
        debian_changelog: str | changelog.Changelog | None = None,
        launchpad_handle: Launchpad | None = None,
        source_dir: str | None = None,
        debian_tar: str | Path | None = None,
    ):
        self._source_dir: str | None = None
        if source_dir:
            self.source_dir = source_dir

            if debian_changelog is None:
                debian_changelog = os.path.join(self.source_dir, "debian/changelog")

        self._debian_tar: Path | None = None
        if isinstance(debian_tar, str):
            self._debian_tar = Path(debian_tar)
        elif isinstance(debian_tar, Path):
            self._debian_tar = debian_tar

        if self._debian_tar and not self._debian_tar.is_file():
            raise ValueError("invalid path for debian tar")

        self._changelog: changelog.Changelog | None = None
        if isinstance(debian_changelog, str):
            with open(debian_changelog, "r") as f:
                self._changelog = changelog.Changelog(f)

        elif isinstance(debian_changelog, changelog.Changelog):
            self._changelog = debian_changelog

        elif self._debian_tar is not None:
            with tarfile.open(self._debian_tar, "r:*") as tar:
                try:
                    changelog_from_tar = tar.extractfile("debian/changelog")
                except KeyError:
                    # In most cases, we could access debian/changelog directly,
                    # i.e. when this is really a .debian.tar.xz. But for native
                    # packages, we need <package_name>/debian/changelog, but
                    # do not necessarily know the package name yet.
                    dirs: list[Path] = []
                    for member in tar.getmembers():
                        if not member.isdir():
                            continue

                        p = Path(member.name)
                        if len(p.parts) != 1:
                            continue

                        dirs.append(p)

                    if len(dirs) != 1:
                        raise ValueError(f"invalid content in {self._debian_tar}")

                    changelog_from_tar = tar.extractfile(
                        str(dirs[0] / "debian/changelog")
                    )

                self._changelog = changelog.Changelog(changelog_from_tar)

        elif debian_changelog is not None:
            raise ValueError("invalid type for changelog")

        self._changes: deb822.Changes | None = None
        if changes is not None:
            self.changes = changes

        elif self._source_dir and self._changelog:
            # If we have a source dir and a changelog, but no changes were given,
            # we can try to infer the path to the changes file.
            name = self.get_source_package_name()
            version = self.get_package_version()
            version_no_epoch = version.full_version.removeprefix(f"{version.epoch}:")
            changes = os.path.join(
                self._source_dir,
                f"../{name}_{version_no_epoch}_source.changes",
            )
            if os.path.exists(changes):
                self.changes = changes

        if not any((self._changes, self._changelog)):
            raise ValueError("context requires at least one of changes or changelog")

        self._lp: Launchpad | None = None
        if launchpad_handle is not None:
            self._lp = launchpad_handle

    @property
    def changes(self) -> deb822.Changes:
        if not self._changes:
            raise MissingContextException("missing context for changes file")
        assert self._changes is not None

        return self._changes

    @changes.setter
    def changes(self, changes: str | deb822.Changes):
        if isinstance(changes, str):
            with open(changes, "r") as f:
                self._changes = deb822.Changes(f)

        elif isinstance(changes, deb822.Changes):
            self._changes = changes

        else:
            raise ValueError("invalid type for changes file")

    def changelog_entry_by_index(self, index: int) -> changelog.ChangeBlock:
        if not self._changelog:
            raise MissingContextException("missing context for changelog entry")
        assert self._changelog is not None

        return self._changelog[index]

    @property
    def changelog_entry(self) -> changelog.ChangeBlock:
        return self.changelog_entry_by_index(0)

    @property
    def lp(self) -> Launchpad:
        if not self._lp:
            self._lp = Launchpad.login_anonymously("ubuntu-lint", "production")

        return self._lp

    @property
    def source_dir(self) -> str:
        if not self._source_dir:
            raise MissingContextException("missing context for source dir")
        assert self._source_dir is not None

        return self._source_dir

    @source_dir.setter
    def source_dir(self, source_dir: str):
        if not os.path.isdir(os.path.join(source_dir, "debian")):
            raise ValueError(f"{source_dir} does not look like a debian source package")

        self._source_dir = source_dir

    @property
    def debian_tar(self) -> Path:
        if self._debian_tar is None:
            raise MissingContextException("missing context for debian tar")

        return self._debian_tar

    def lint_fail(self, msg: str):
        raise LintException(msg)

    def lint_skip(self, msg: str):
        raise LintException(msg, result=LintResult.SKIP)

    def lint_warn(self, msg: str):
        raise LintException(msg, result=LintResult.WARN)

    def lint_error(self, msg: str):
        raise LintException(msg, result=LintResult.ERROR)

    def _ensure_get[T](
        self,
        what: str,
        from_changes: T | None,
        from_changelog: T | None,
    ) -> T:
        if (from_changes, from_changelog) == (None, None):
            raise MissingContextException(f"missing required context for {what}")

        if (
            None not in (from_changes, from_changelog)
            and from_changes != from_changelog
        ):
            raise InconsistentContextException(what, from_changes, from_changelog)

        ret = from_changes or from_changelog
        assert ret is not None

        return ret

    def get_distribution(self) -> str:
        """
        Return the name of the distribution associated with the change, e.g.
        noble, noble-security, etc.
        """
        try:
            from_changes = self.changes.get("Distribution")
        except MissingContextException:
            from_changes = None
        try:
            from_changelog = str(self.changelog_entry.distributions)
        except MissingContextException:
            from_changelog = None

        return self._ensure_get("distribution", from_changes, from_changelog)

    def get_series(self) -> str:
        """
        Return the name of the series associated with the change, e.g.
        noble, resolute, etc.
        """
        return self.get_distribution().partition("-")[0]

    def is_unreleased(self) -> bool:
        return self.get_series() == "UNRELEASED"

    def is_stable_release(self) -> bool:
        """
        Returns True if the context represents an upload targeting a stable
        release.
        """
        di = distro_info.UbuntuDistroInfo()
        stable = set(di.supported() + di.supported_esm()) - set([di.devel()])

        return self.get_series() in stable

    def get_launchpad_bugs_fixed(self) -> list[str]:
        """
        Returns the list of bugs fixed by this upload, according to
        changes file or most recent changelog entry.
        """
        try:
            from_changes = self.changes.get("Launchpad-Bugs-Fixed", "").split()
        except MissingContextException:
            from_changes = None

        try:
            from_changelog = [str(n) for n in self.changelog_entry.lp_bugs_closed]
        except MissingContextException:
            from_changelog = None

        return self._ensure_get("launchpad bugs fixed", from_changes, from_changelog)

    def get_package_version(self) -> debian_support.Version:
        """
        Returns the current package version, according to changes
        file or most recent changelog entry.
        """
        try:
            from_changes = debian_support.Version(self.changes.get("Version"))
        except MissingContextException:
            from_changes = None

        try:
            from_changelog = self.changelog_entry.version
        except MissingContextException:
            from_changelog = None

        return self._ensure_get("version", from_changes, from_changelog)

    def get_source_package_name(self):
        try:
            from_changes = self.changes.get("Source")
        except MissingContextException:
            from_changes = None

        try:
            from_changelog = self.changelog_entry.package
        except MissingContextException:
            from_changelog = None

        return self._ensure_get("source name", from_changes, from_changelog)
