#!/usr/bin/env python3

import abc
import argparse
import difflib
import json
import logging
import re
import selectors
import subprocess
import sys
import urllib.request
from pathlib import Path

logger = logging.getLogger(__name__)

PYTHON_VERSION = "3.12"
EOL_PYTHON_URL = "https://endoflife.date/api/python.json"

CONTENT_QA = r"""## QA

To ensure that new releases do not introduce regressions, and support existing
and newer platforms, we have to test that the produced packages work as expected.

Check the following:

- [ ] Make sure that the tip of the `main` branch passes the CI tests.
- [ ] Make sure that the Apple account has a valid application password and has
      agreed to the latest Apple terms (see [macOS release](#macos-release)
      section).

Because it is repetitive, we wrote a script to help with the QA.
It can run the tasks for you, pausing when it needs manual intervention.

You can run it with a command like:

```bash
poetry run ./dev_scripts/qa.py {distro}-{version}
```

### The checklist

- [ ] Create a test build in Windows and make sure it works:
  - [ ] Check if the suggested Python version is still supported.
  - [ ] Create a new development environment with Poetry.
  - [ ] Build the container image and ensure the development environment uses
    the new image.
  - [ ] Download the OCR language data using `./install/common/download-tessdata.py`
  - [ ] Run the Dangerzone tests.
  - [ ] Build and run the Dangerzone .exe
  - [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in macOS (Intel CPU) and make sure it works:
  - [ ] Check if the suggested Python version is still supported.
  - [ ] Create a new development environment with Poetry.
  - [ ] Build the container image and ensure the development environment uses
    the new image.
  - [ ] Download the OCR language data using `./install/common/download-tessdata.py`
  - [ ] Run the Dangerzone tests.
  - [ ] Create and run an app bundle.
  - [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in macOS (M1/2 CPU) and make sure it works:
  - [ ] Check if the suggested Python version is still supported.
  - [ ] Create a new development environment with Poetry.
  - [ ] Build the container image and ensure the development environment uses
    the new image.
  - [ ] Download the OCR language data using `./install/common/download-tessdata.py`
  - [ ] Run the Dangerzone tests.
  - [ ] Create and run an app bundle.
  - [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in the most recent Ubuntu LTS platform (Ubuntu 24.04
  as of writing this) and make sure it works:
  - [ ] Create a new development environment with Poetry.
  - [ ] Build the container image and ensure the development environment uses
    the new image.
  - [ ] Download the OCR language data using `./install/common/download-tessdata.py`
  - [ ] Run the Dangerzone tests.
  - [ ] Create a .deb package and install it system-wide.
  - [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in the most recent Fedora platform (Fedora 41 as of
  writing this) and make sure it works:
  - [ ] Create a new development environment with Poetry.
  - [ ] Build the container image and ensure the development environment uses
    the new image.
  - [ ] Download the OCR language data using `./install/common/download-tessdata.py`
  - [ ] Run the Dangerzone tests.
  - [ ] Create an .rpm package and install it system-wide.
  - [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below).
- [ ] Create a test build in the most recent Qubes Fedora template (Fedora 40 as
  of writing this) and make sure it works:
  - [ ] Create a new development environment with Poetry.
  - [ ] Run the Dangerzone tests.
  - [ ] Create a Qubes .rpm package and install it system-wide.
  - [ ] Ensure that the Dangerzone application appears in the "Applications"
    tab.
  - [ ] Test some QA scenarios (see [Scenarios](#Scenarios) below) and make sure
    they spawn disposable qubes.
"""

CONTENT_QA_SCENARIOS = r"""### Scenarios

#### 1. Dangerzone correctly identifies that Docker/Podman is not installed

_(Only for MacOS / Windows)_

Temporarily hide the Docker/Podman binaries, e.g., rename the `docker` /
`podman` binaries to something else. Then run Dangerzone. Dangerzone should
prompt the user to install Docker/Podman.

#### 2. Dangerzone correctly identifies that Docker is not running

_(Only for MacOS / Windows)_

Stop the Docker Desktop application. Then run Dangerzone. Dangerzone should
prompt the user to start Docker Desktop.


#### 3. Updating Dangerzone handles external state correctly.

_(Applies to Windows/MacOS)_

Install the previous version of Dangerzone, downloaded from the website.

Open the Dangerzone application and enable some non-default settings.
**If there are new settings, make sure to change those as well**.

Close the Dangerzone application and get the container image for that
version. For example:

```
$ docker images dangerzone.rocks/dangerzone
REPOSITORY                   TAG         IMAGE ID      CREATED       SIZE
dangerzone.rocks/dangerzone  <tag>       <image ID>    <date>        <size>
```

Then run the version under QA and ensure that the settings remain changed.

Afterwards check that new docker image was installed by running the same command
and seeing the following differences:

```
$ docker images dangerzone.rocks/dangerzone
REPOSITORY                   TAG         IMAGE ID        CREATED       SIZE
dangerzone.rocks/dangerzone  <other tag> <different ID>  <newer date>  <different size>
```

#### 4. Dangerzone successfully installs the container image

_(Only for Linux)_

Remove the Dangerzone container image from Docker/Podman. Then run Dangerzone.
Dangerzone should install the container image successfully.

#### 5. Dangerzone retains the settings of previous runs

Run Dangerzone and make some changes in the settings (e.g., change the OCR
language, toggle whether to open the document after conversion, etc.). Restart
Dangerzone. Dangerzone should show the settings that the user chose.

#### 6. Dangerzone reports failed conversions

Run Dangerzone and convert the `tests/test_docs/sample_bad_pdf.pdf` document.
Dangerzone should fail gracefully, by reporting that the operation failed, and
showing the following error message:

> The document format is not supported

#### 7. Dangerzone succeeds in converting multiple documents

Run Dangerzone against a list of documents, and tick all options. Ensure that:
* Conversions take place sequentially.
* Attempting to close the window while converting asks the user if they want to
  abort the conversions.
* Conversions are completed successfully.
* Conversions show individual progress in real-time (double-check for Qubes).
* _(Only for Linux)_ The resulting files open with the PDF viewer of our choice.
* OCR seems to have detected characters in the PDF files.
* The resulting files have been saved with the proper suffix, in the proper
  location.
* The original files have been saved in the `unsafe/` directory.

#### 8. Dangerzone is able to handle drag-n-drop

Run Dangerzone against a set of documents that you drag-n-drop. Files should be
added and conversion should run without issue.

> [!TIP]
> On our end-user container environments for Linux, we can start a file manager
> with `thunar &`.

#### 9. Dangerzone CLI succeeds in converting multiple documents

_(Only for Windows and Linux)_

Run Dangerzone CLI against a list of documents. Ensure that conversions happen
sequentially, are completed successfully, and we see their progress.

#### 10. Dangerzone can open a document for conversion via right-click -> "Open With"

_(Only for Windows, MacOS and Qubes)_

Go to a directory with office documents, right-click on one, and click on "Open
With". We should be able to open the file with Dangerzone, and then convert it.

#### 11. Dangerzone shows helpful errors for setup issues on Qubes

_(Only for Qubes)_

Check what errors does Dangerzone throw in the following scenarios. The errors
should point the user to the Qubes notifications in the top-right corner:

1. The `dz-dvm` template does not exist. We can trigger this scenario by
   temporarily renaming this template.
2. The Dangerzone RPC policy does not exist. We can trigger this scenario by
   temporarily renaming the `dz.Convert` policy.
3. The `dz-dvm` disposable Qube cannot start due to insufficient resources. We
   can trigger this scenario by temporarily increasing the minimum required RAM
   of the `dz-dvm` template to more than the available amount.
"""

CONTENT_BUILD_DEBIAN_UBUNTU = r"""## Debian/Ubuntu

Install dependencies:

<table>
  <tr>
      <td>
<details>
  <summary><i>:memo: Expand this section if you are on Ubuntu 22.04 (Jammy).</i></summary>
  </br>

  The `conmon` version that Podman uses and Ubuntu Jammy ships, has a bug
  that gets triggered by Dangerzone
  (more details in https://github.com/freedomofpress/dangerzone/issues/685).
  If you want to run Dangerzone from source, you are advised to install a
  patched `conmon` version. A simple way to do so is to enable our
  apt-tools-prod repo, just for the `conmon` package:

  ```bash
  sudo cp ./dev_scripts/apt-tools-prod.sources /etc/apt/sources.list.d/
  sudo cp ./dev_scripts/apt-tools-prod.pref /etc/apt/preferences.d/
  ```

  The `conmon` package provided in the above repo was built with the
  following [instructions](https://github.com/freedomofpress/maint-dangerzone-conmon/tree/ubuntu/jammy/fpf).
  Alternatively, you can install a `conmon` version higher than `v2.0.25` from
  any repo you prefer.

</details>
    </td>
  </tr>
</table>


<table>
  <tr>
      <td>
<details>
  <summary><i>:memo: Expand this section if you are on Ubuntu 20.04 (Focal).</i></summary>
  </br>

  The default Python version that ships with Ubuntu Focal (3.8) is not
  compatible with PySide6, which requires Python 3.9 or greater.

  You can install Python 3.9 using the `python3.9` package.

  ```bash
  sudo apt install -y python3.9
  ```

  Poetry will automatically pick up the correct version when running.
</details>
    </td>
  </tr>
</table>


```sh
sudo apt install -y podman dh-python build-essential make libqt6gui6 \
    pipx python3 python3-dev
```

Install Poetry using `pipx` (recommended) and add it to your `$PATH`:

_(See also a list of [alternative installation
methods](https://python-poetry.org/docs/#installation))_

```sh
pipx ensurepath
pipx install poetry
pipx inject poetry poetry-plugin-export
```

After this, restart the terminal window, for the `poetry` command to be in your
`$PATH`.


Clone this repository:

```
git clone https://github.com/freedomofpress/dangerzone/
```

Change to the `dangerzone` folder, and install the poetry dependencies:

> **Note**: due to an issue with [poetry](https://github.com/python-poetry/poetry/issues/1917), if it prompts for your keyring, disable the keyring with `keyring --disable` and run the command again.

```
cd dangerzone
poetry install
```

Build the latest container:

```sh
python3 ./install/common/build-image.py
```

Download the OCR language data:

```sh
python3 ./install/common/download-tessdata.py
```

Run from source tree:

```sh
# start a shell in the virtual environment
poetry shell

# run the CLI
./dev_scripts/dangerzone-cli --help

# run the GUI
./dev_scripts/dangerzone
```

Create a .deb:

```sh
./install/linux/build-deb.py
```
"""

CONTENT_BUILD_FEDORA = r"""## Fedora

Install dependencies:

```sh
sudo dnf install -y rpm-build podman python3 python3-devel python3-poetry-core \
    pipx qt6-qtbase-gui
```

<table>
  <tr>
      <td>
<details>
  <summary><i>:memo: Expand this section if you are on Fedora 41.</i></summary>
  </br>

  The default Python version that ships with Fedora 41 (3.13) is not
  compatible with PySide6, which requires Python 3.12 or earlier.

  You can install Python 3.12 using the `python3.12` package.

  ```bash
  sudo dnf install -y python3.12
  ```

  Poetry will automatically pick up the correct version when running.
</details>
    </td>
  </tr>
</table>

Install Poetry using `pipx`:

```sh
pipx install poetry
pipx inject poetry poetry-plugin-export
```

Clone this repository:

```
git clone https://github.com/freedomofpress/dangerzone/
```

Change to the `dangerzone` folder, and install the poetry dependencies:

> **Note**: due to an issue with [poetry](https://github.com/python-poetry/poetry/issues/1917), if it prompts for your keyring, disable the keyring with `keyring --disable` and run the command again.

```
cd dangerzone
poetry install
```

Build the latest container:

```sh
python3 ./install/common/build-image.py
```

Download the OCR language data:

```sh
python3 ./install/common/download-tessdata.py
```

Run from source tree:

```sh
# start a shell in the virtual environment
poetry shell

# run the CLI
./dev_scripts/dangerzone-cli --help

# run the GUI
./dev_scripts/dangerzone
```

> [!NOTE]
> Prefer running the following command in a Fedora development environment,
> created by `./dev_script/env.py`.

Create a .rpm:

```sh
./install/linux/build-rpm.py
```
"""

CONTENT_BUILD_WINDOWS = r"""## Windows

Install [Docker Desktop](https://www.docker.com/products/docker-desktop).

Install the latest version of Python 3.12 (64-bit) [from python.org](https://www.python.org/downloads/windows/). Make sure to check the "Add Python 3.12 to PATH" checkbox on the first page of the installer.


Install Microsoft Visual C++ 14.0 or greater. Get it with ["Microsoft C++ Build Tools"](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and make sure to select "Desktop development with C++" when installing.

Install [poetry](https://python-poetry.org/). Open PowerShell, and run:

```
python -m pip install poetry poetry-plugin-export
```

Install git from [here](https://git-scm.com/download/win), open a Windows terminal (`cmd.exe`) and clone this repository:

```
git clone https://github.com/freedomofpress/dangerzone/
```

Change to the `dangerzone` folder, and install the poetry dependencies:

```
cd dangerzone
poetry install
```

Build the dangerzone container image:

```sh
python3 .\install\common\build-image.py
```

Download the OCR language data:

```sh
python3 .\install\common\download-tessdata.py
```

After that you can launch dangerzone during development with:

```
# start a shell in the virtual environment
poetry shell

# run the CLI
.\dev_scripts\dangerzone-cli.bat --help

# run the GUI
.\dev_scripts\dangerzone.bat
```
"""


class Reference:
    """Reference a Markdown section in our docs.

    This class holds a reference to a Markdown section in our docs, and compares it
    against to a stored version of this section in this file. The purpose of this class
    is to warn devs about changes in the docs that may affect the code as well. By
    having the stored doc section and the code in the same file, we limit the cases
    where those two may diverge.
    """

    REPO_URL = "https://github.com/freedomofpress/dangerzone"
    instances = []

    def __init__(self, md_path, content):
        """Initialize the class using the path for the docs and the cached section."""
        self.md_path = md_path
        self.content = content

        # Figure out the heading of the section, and the GitHub markdown anchor, from
        # the content.
        first_line = self.content.split("\n", 1)[0]
        self.heading_title = re.sub(r"^\W+", "", first_line)
        self.md_anchor = self.get_md_anchor()

        self.url = f"{self.REPO_URL}/blob/main/{self.md_path}#{self.md_anchor}"
        self.instances.append(self)

    def ensure_up_to_date(self):
        """Check if the referenced text has changed.

        This is a consistency check to ensure that the specified QA instructions in this
        script are consistent with the ones described in the docs. If the check fails,
        this file needs to be updated.
        """
        # Get section's text (as the file is now)
        with open(self.md_path, "r") as md_file:
            md_text_current = md_file.read()
            current_section_text = self.find_section_text(md_text_current)

        # Check if there have been any changes from the cached section stored in this
        # file.
        if not self.content == current_section_text:
            logger.error(
                f"The contents of section '{self.heading_title}' in file"
                f" '{self.md_path}' have changed since this file was last updated!"
            )
            logger.error("Diff follows:")
            sys.stderr.writelines(self.diff(current_section_text))
            logger.error(
                "Please ensure the instructions in this script are up"
                " to date with the respective doc section, and then update the cached"
                " section in this file."
            )
            sys.exit(1)

    def find_section_text(self, md_text):
        """Find a section's content in a provided Markdown string."""
        start = end = None
        orig_lines = md_text.splitlines(keepends=True)
        cur_lines = self.content.splitlines(keepends=True)
        for i, line in enumerate(orig_lines):
            if line == cur_lines[0]:
                break

        start = i
        end = len(cur_lines) + i

        # Ensure that no extra content has been added in that section, until a new
        # heading begins.
        for i, line in enumerate(orig_lines[end:]):
            if orig_lines[i] and orig_lines[i][0] == "#":
                break

        end += i

        return "".join(orig_lines[start:end])

    def get_md_anchor(self):
        """Attempt to get an anchor link to the markdown section on github.

        Related: https://stackoverflow.com/questions/72536973/how-are-github-markdown-anchor-links-constructed
        """
        # Remove '#' from header
        anchor = re.sub("^[#]+", "", self.heading_title)
        # Format
        anchor = anchor.strip().lower()
        # Convert spaces to dashes
        anchor = anchor.replace(" ", "-")
        # Remove non-alphanumeric (except dash and underscore)
        anchor = re.sub("[^a-zA-Z-_]", "", anchor)

        return anchor

    def diff(self, source):
        """Return a diff between the section in the docs and the stored one."""
        source = source.splitlines(keepends=True)
        content = self.content.splitlines(keepends=True)
        diff = difflib.unified_diff(
            source, content, fromfile=self.md_path, tofile="qa.py"
        )
        return diff


class QABase(abc.ABC):
    """Base class for the QA tasks."""

    platforms = {}

    REF_QA = Reference("QA.md", content=CONTENT_QA)
    REF_QA_SCENARIOS = Reference("QA.md", content=CONTENT_QA_SCENARIOS)

    # The following class method is available since Python 3.6. For more details, see:
    # https://docs.python.org/3.6/whatsnew/3.6.html#pep-487-simpler-customization-of-class-creation
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        # NOTE: Not all subclasses correspond to valid platforms. Some subclasses may
        # provide some basic functionality for an OS type or distro, but not a specific
        # version.
        if cls.get_id():
            cls.platforms[cls.get_id()] = cls

    def __init__(self, try_auto=False, skip_manual=False, debug=False):
        self.debug = debug
        self.try_auto = try_auto
        self.skip_manual = skip_manual
        self.src = (
            subprocess.run(
                [
                    "git",
                    "rev-parse",
                    "--show-toplevel",
                ],
                stdout=subprocess.PIPE,
            )
            .stdout.decode()
            .strip("\n")
        )

    def task(*msgs, ref=None, auto=False):
        """Decorator for running a task automatically.

        This decorator does the following:
        * Check if the user has asked to run the tasks automatically.
          - If not, ask the user if they want to try it out.
          - If yes, run the task (decorated function).
        * If an exception occurs, allow the user to skip the task, or stop the
          execution.

        Args:
            msgs: A list of messages that we should show to the user, one message per
              line.
            ref: A link that can be used as a reference for a specific task.
        """

        def decorator(func):
            def inner(self, *args, **kwargs):
                # If the reference is actually defined in subclasses, grab it from
                # `self`.
                if isinstance(ref, str):
                    _ref = getattr(self, ref)
                else:
                    _ref = ref

                # Assume that the first line is the description of the task, and
                # decorate it with some "=" signs.
                dec_msgs = list(msgs)
                num_equals = (80 - 2 - len(msgs[0])) // 2 or 3
                dec_msgs.insert(0, "")
                dec_msgs[1] = "=" * num_equals + " " + msgs[0] + " " + "=" * num_equals
                self.describe(*dec_msgs, ref=_ref)

                # Handle manual tasks.
                if not auto:
                    if self.skip_manual:
                        logger.info("Skipping manual tasks, as instructed")
                        return
                    else:
                        return func(self, *args, **kwargs)

                # Detect if the task should run automatically, either because the user
                # has provided the `--try-auto` flag, or because they typed "auto" when
                # prompted.
                try_auto = False
                if self.try_auto:
                    try_auto = True
                else:
                    prompt = "Type '[a]uto' to try automatically, or '[s]kip' to skip: "
                    choice = self.prompt(
                        prompt=prompt, choices=["a", "auto", "s", "skip"]
                    )
                    try_auto = choice in ("a", "auto")

                # Run the task automatically, if the user asked to, else assume that the
                # user has run it manually.
                if try_auto:
                    logger.info("Trying automatically...")
                    out = func(self, *args, **kwargs)
                    logger.info("Completed successfully")
                    return out

            return inner

        return decorator

    def run(self, *args):
        """Run a command with extra debug logs."""
        # Construct the command line representation of the provided arguments, by taking
        # the arguments and joining them with a space character. If an argument already
        # has a space character in it, quote it before adding it to the CLI
        # representation.
        cmd_str = ""
        for arg in args:
            if " " in arg:
                cmd_str += " '" + arg + "'"
            else:
                cmd_str += " " + arg

        logger.debug("Running %s", cmd_str)
        try:
            subprocess.run(args, check=True)
            logger.debug("Successfully ran %s", cmd_str)
        except subprocess.SubprocessError:
            logger.debug("Failed to run %s", cmd_str)
            raise

    def try_run(self, *args):
        """Try to run a command and return True/False, depending on the result."""
        try:
            self.run(*args)
            return True
        except subprocess.SubprocessError:
            return False

    def describe(self, *msgs, ref=None):
        """Print a task's description.

        Log one message per line, and optionally allow a URL as a reference.
        """
        for msg in msgs:
            logger.info(msg)
        if ref:
            logger.info(
                f"For reference, see section '{ref.heading_title}' of '{ref.md_path}'"
                f" either locally, or in '{ref.url}'"
            )

    def _consume_stdin(self):
        """Consume the stdin of the process.

        Consume the stdin of the process so that stray key presses from previous steps
        do not spill over.

        It's imperative that we don't block in our attempt to consume the process'
        stdin. Typically, this requires setting the stdin file descriptor to
        non-blocking mode. However, this is not straight-forward on Windows.

        Since we don't care about consuming the file descriptor *fast*, we can create a
        tight loop that checks if the file descriptor has data to read from, and then
        read a byte (else we risk blocking). Once the file descriptor has no data to
        read, we can return.
        """
        sel = selectors.DefaultSelector()
        sel.register(sys.stdin, selectors.EVENT_READ)
        while sel.select(timeout=0):
            sys.stdin.read(1)

    def prompt(self, *msgs, ref=None, prompt=None, choices=None):
        """Print the task's description, and prompt the user for an action."""
        self.describe(*msgs, ref=ref)
        if prompt is None:
            if choices is None:
                prompt = "Press Enter once you completed this step to continue: "
            else:
                prompt = "Valid choices are %s: " % ", ".join(choices)

        if self.skip_manual:
            logger.info("Skipping manual tasks, as instructed")
            return None
        else:
            self._consume_stdin()
            # If the dev has provided a list of valid choices, do not let the prompt
            # proceed until the user has provided one of those choices.
            while True:
                choice = input(prompt)
                if not choices or choice in choices:
                    return choice

    @task("Begin the QA scenarios", ref=REF_QA_SCENARIOS)
    def qa_scenarios(self, skip=None):
        """Common prompt for the QA scenarios.

        This method suggests to the user a way to check the QA scenarios, in a
        Dangerzone environment. Then, it iterates through them, prints their
        description, and asks the user if they pass.
        """
        skip = skip or []
        self.describe(
            "You can execute into a test environment with:",
            "",
            f"    cd {self.src}",
            f"    ./dev_scripts/env.py --distro {self.DISTRO} --version"
            f" {self.VERSION} run bash",
            "",
            "and run either `dangerzone` or `dangerzone-cli`",
        )

        scenarios = [
            s[5:] for s in CONTENT_QA_SCENARIOS.splitlines() if s.startswith("#### ")
        ]

        for num, scenario in enumerate(scenarios):
            self.describe(scenario)
            if num + 1 in skip:
                self.describe("Skipping for the current platform")
            else:
                self.prompt("Does it pass?", choices=["y", "n"])
        logger.info("Successfully completed QA scenarios")

    @task("Download Tesseract data", auto=True)
    def download_tessdata(self):
        self.run("python", str(Path("install", "common", "download-tessdata.py")))

    @classmethod
    @abc.abstractmethod
    def get_id(cls):
        """Get the identifier of this class for CLI usage."""
        raise NotImplementedError("The class has not specified an identifier")

    @abc.abstractmethod
    def start(self):
        """Start the QA tests for a platform."""
        raise NotImplementedError("No steps have been defined")


# TODO: Test this class on Windows thorougly, and finish it.
class QAWindows(QABase):
    """Class for the Windows QA tasks."""

    REF_BUILD = Reference("BUILD.md", content=CONTENT_BUILD_WINDOWS)

    def _consume_stdin(self):
        # NOTE: We can't use select() on Windows. See:
        # https://docs.python.org/3/library/select.html#select.select
        import msvcrt

        while msvcrt.kbhit():
            msvcrt.getch()

    def get_latest_python_release(self):
        with urllib.request.urlopen(EOL_PYTHON_URL) as f:
            resp = f.read()
            releases = json.loads(resp)
            for release in releases:
                if release["cycle"] == PYTHON_VERSION:
                    # Transform the Python version string (e.g., "3.12.7") into a list
                    # (e.g., [3, 12, 7]), and return it
                    return [int(num) for num in release["latest"].split(".")]

            raise RuntimeError(
                f"Could not find a Python release for version {PYTHON_VERSION}"
            )

    @QABase.task(
        f"Install the latest version of Python {PYTHON_VERSION}", ref=REF_BUILD
    )
    def install_python(self):
        logger.info("Getting latest Python release")
        try:
            latest_version = self.get_latest_python_release()
        except Exception:
            logger.error("Could not verify that the latest Python version is installed")

        cur_version = list(sys.version_info[:3])
        if latest_version > cur_version:
            self.prompt(
                f"You need to install the latest Python version ({latest_version})"
            )
        elif latest_version == cur_version:
            logger.info(
                f"Verified that the latest Python version ({latest_version}) is installed"
            )

    @QABase.task("Install and Run Docker Desktop", ref=REF_BUILD)
    def install_docker(self):
        logger.info("Checking if Docker Desktop is installed and running")
        if not self.try_run("docker", "info"):
            logger.info("Failed to verify that Docker Desktop is installed and running")
            self.prompt("Ensure that Docker Desktop is installed and running")
        else:
            logger.info("Verified that Docker Desktop is installed and running")

    @QABase.task(
        "Install Poetry and the project's dependencies", ref=REF_BUILD, auto=True
    )
    def install_poetry(self):
        self.run("python", "-m", "pip", "install", "poetry", "poetry-plugin-export")
        self.run("poetry", "install", "--sync")

    @QABase.task("Build Dangerzone container image", ref=REF_BUILD, auto=True)
    def build_image(self):
        self.run("python", r".\install\common\build-image.py")

    @QABase.task("Run tests", ref="REF_BUILD", auto=True)
    def run_tests(self):
        # NOTE: Windows does not have Makefile by default.
        self.run(
            "poetry", "run", "pytest", "-v", "--ignore", r"tests\test_large_set.py"
        )

    @QABase.task("Build Dangerzone .exe", ref="REF_BUILD", auto=True)
    def build_dangerzone_exe(self):
        self.run("poetry", "run", "python", r".\setup-windows.py", "build")

    @classmethod
    def get_id(cls):
        return "windows"

    def start(self):
        self.install_python()
        self.install_docker()
        self.install_poetry()
        self.build_image()
        self.download_tessdata()
        self.run_tests()
        self.build_dangerzone_exe()


class QALinux(QABase):
    """Base class for all the Linux QA tasks."""

    REF_BUILD = None
    DISTRO = None
    VERSION = None

    def container_run(self, *args):
        """Run a command inside a Dangerzone environment."""
        self.run(
            f"{self.src}/dev_scripts/env.py",
            "--distro",
            self.DISTRO,
            "--version",
            self.VERSION,
            "run",
            "--dev",
            *args,
        )

    def shell_run(self, *args):
        """Run a shell command inside a Dangerzone environment and source dir."""
        args_str = " ".join(args)
        self.container_run("bash", "-c", f"cd dangerzone; {args_str}")

    def poetry_run(self, *args):
        """Run a command via Poetry inside a Dangerzone environment."""
        self.shell_run("poetry", "run", *args)

    @QABase.task(
        "Create Dangerzone build environment",
        "You can also run './dev_scripts/env.py ... build-dev'",
        ref="REF_BUILD",
        auto=True,
    )
    def build_dev_image(self):
        self.run(
            f"{self.src}/dev_scripts/env.py",
            "--distro",
            self.DISTRO,
            "--version",
            self.VERSION,
            "build-dev",
        )

    @QABase.task("Build Dangerzone image", ref="REF_BUILD", auto=True)
    def build_container_image(self):
        self.shell_run("python3 ./install/common/build-image.py")
        # FIXME: We need to automate this part, simply by checking that the created
        # image is in `share/image-id.txt`.
        self.prompt("Ensure that the environment uses the created image")

    @QABase.task("Run tests", ref="REF_BUILD", auto=True)
    def run_tests(self):
        self.poetry_run("make", "test")

    def build_package(self):
        """Build the Dangerzone .deb/.rpm package"""
        # Building a pakage is platform-specific, so subclasses must implement it.
        raise NotImplementedError("Building a package is not implemented")

    @QABase.task("Create Dangerzone QA environment", ref="REF_BUILD", auto=True)
    def build_qa_image(self):
        self.run(
            f"{self.src}/dev_scripts/env.py",
            "--distro",
            self.DISTRO,
            "--version",
            self.VERSION,
            "build",
        )

    @classmethod
    def get_id(cls):
        """Return the ID for the QA class.

        If the QA class is a base one, then either the distro or the version will be
        None. In this case, return None as well, to signify that this class does not
        correspond to a specific platform."""
        if not cls.DISTRO or not cls.VERSION:
            return None
        else:
            return f"{cls.DISTRO}-{cls.VERSION}"

    def start(self):
        self.build_dev_image()
        self.build_container_image()
        self.download_tessdata()
        self.run_tests()
        self.build_package()
        self.build_qa_image()
        self.qa_scenarios(skip=[1, 2, 3, 10, 11])


class QADebianBased(QALinux):
    """Base class for Debian-based distros.

    This class simply points to the proper build instructions, and builds the Debian
    package.
    """

    REF_BUILD = Reference("BUILD.md", content=CONTENT_BUILD_DEBIAN_UBUNTU)

    @QABase.task("Build .deb", ref=REF_BUILD, auto=True)
    def build_package(self):
        self.shell_run("./install/linux/build-deb.py")


class QADebianBullseye(QADebianBased):
    DISTRO = "debian"
    VERSION = "bullseye"


class QADebianBookworm(QADebianBased):
    DISTRO = "debian"
    VERSION = "bookworm"


class QADebianTrixie(QADebianBased):
    DISTRO = "debian"
    VERSION = "trixie"


class QAUbuntu2004(QADebianBased):
    DISTRO = "ubuntu"
    VERSION = "20.04"


class QAUbuntu2204(QADebianBased):
    DISTRO = "ubuntu"
    VERSION = "22.04"


class QAUbuntu2404(QADebianBased):
    DISTRO = "ubuntu"
    VERSION = "24.04"


class QAUbuntu2410(QADebianBased):
    DISTRO = "ubuntu"
    VERSION = "24.10"


class QAFedora(QALinux):
    """Base class for Fedora distros.

    This class simply points to the proper build instructions, and builds the RPM
    package.
    """

    DISTRO = "fedora"
    REF_BUILD = Reference("BUILD.md", content=CONTENT_BUILD_FEDORA)

    @QABase.task("Build .rpm", ref=REF_BUILD, auto=True)
    def build_package(self):
        self.container_run(
            "./dangerzone/install/linux/build-rpm.py",
        )


class QAFedora41(QAFedora):
    VERSION = "41"


class QAFedora40(QAFedora):
    VERSION = "40"


def parse_args():
    parser = argparse.ArgumentParser(
        prog=sys.argv[0],
        description="Run QA tests for a platform",
    )
    parser.add_argument(
        "platform",
        choices=sorted(QABase.platforms.keys()),
        help="Run QA tests for the provided platform",
        nargs="?",
    )
    parser.add_argument(
        "--try-auto",
        action="store_true",
        default=False,
        help="Try to run the automated parts of the QA steps",
    )
    # FIXME: Find a better way to skip tasks in non-interactive environments, while
    # retaining the ability to be semi-prompted in interactive environments.
    parser.add_argument(
        "--skip-manual",
        action="store_true",
        default=False,
        help="Skip the manual parts of the QA steps",
    )
    parser.add_argument(
        "--debug",
        action="store_true",
        default=False,
        help="Enable debug logs",
    )
    parser.add_argument(
        "--check-refs",
        action="store_true",
        default=False,
        help="Check if references to docs still hold",
    )

    args = parser.parse_args()

    if not args.check_refs and not args.platform:
        parser.print_help(sys.stderr)
        sys.exit(1)

    return args


def setup_logging(debug=False):
    """Simple way to setup logging.

    Copied from: https://docs.python.org/3/howto/logging.html
    """
    # specify level
    if debug:
        lvl = logging.DEBUG
    else:
        lvl = logging.INFO

    logging.basicConfig(
        level=lvl,
        format="%(asctime)s - %(levelname)s - %(message)s",
        datefmt="%Y-%m-%d %H:%M:%S",
    )


def main():
    args = parse_args()
    setup_logging(args.debug)

    for ref in Reference.instances:
        ref.ensure_up_to_date()
    if args.check_refs:
        return

    try:
        qa_cls = QABase.platforms[args.platform]
    except KeyError:
        raise RuntimeError("Unexpected platform: %s", args.platform)

    logger.info("Starting QA tests for %s", args.platform.capitalize())
    qa_cls(args.try_auto, args.skip_manual, args.debug).start()
    logger.info("Successfully completed QA tests for %s", args.platform.capitalize())


if __name__ == "__main__":
    sys.exit(main())
