#!/usr/bin/python

# Copyright (c) 2013, Daniel Jaouen <dcj24@cornell.edu>
# Copyright (c) 2016, Indrajit Raychaudhuri <irc+code@indrajit.com>
#
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import annotations

DOCUMENTATION = r"""
module: homebrew_cask
author:
  - "Indrajit Raychaudhuri (@indrajitr)"
  - "Daniel Jaouen (@danieljaouen)"
  - "Enric Lluelles (@enriclluelles)"
short_description: Install and uninstall homebrew casks
description:
  - Manages Homebrew casks.
extends_documentation_fragment:
  - community.general._attributes
attributes:
  check_mode:
    support: full
  diff_mode:
    support: none
options:
  name:
    description:
      - Name of cask to install or remove.
    aliases: ['cask', 'package', 'pkg']
    type: list
    elements: str
  path:
    description:
      - "':' separated list of paths to search for 'brew' executable."
    default: '/usr/local/bin:/opt/homebrew/bin'
    type: path
  state:
    description:
      - State of the cask.
    choices: ['absent', 'installed', 'latest', 'present', 'removed', 'uninstalled', 'upgraded']
    default: present
    type: str
  sudo_password:
    description:
      - The sudo password to be passed to E(SUDO_ASKPASS).
    type: str
  update_homebrew:
    description:
      - Update homebrew itself first.
      - Note that C(brew cask update) is a synonym for C(brew update).
    type: bool
    default: false
  install_options:
    description:
      - Options flags to install a package.
    aliases: ['options']
    type: list
    elements: str
  accept_external_apps:
    description:
      - Allow external apps.
    type: bool
    default: false
  upgrade_all:
    description:
      - Upgrade all casks.
      - Mutually exclusive with C(upgraded) state.
    type: bool
    default: false
    aliases: ['upgrade']
  greedy:
    description:
      - Upgrade casks that auto update.
      - Passes C(--greedy) to C(brew outdated --cask) when checking if an installed cask has a newer version available, or
        to C(brew upgrade --cask) when upgrading all casks.
    type: bool
    default: false
"""
EXAMPLES = r"""
- name: Install cask
  community.general.homebrew_cask:
    name: alfred
    state: present

- name: Remove cask
  community.general.homebrew_cask:
    name: alfred
    state: absent

- name: Install cask with install options
  community.general.homebrew_cask:
    name: alfred
    state: present
    install_options: 'appdir=/Applications'

- name: Install cask with install options
  community.general.homebrew_cask:
    name: alfred
    state: present
    install_options: 'debug,appdir=/Applications'

- name: Install cask with force option
  community.general.homebrew_cask:
    name: alfred
    state: present
    install_options: force

- name: Allow external app
  community.general.homebrew_cask:
    name: alfred
    state: present
    accept_external_apps: true

- name: Remove cask with force option
  community.general.homebrew_cask:
    name: alfred
    state: absent
    install_options: force

- name: Upgrade all casks
  community.general.homebrew_cask:
    upgrade_all: true

- name: Upgrade all casks with greedy option
  community.general.homebrew_cask:
    upgrade_all: true
    greedy: true

- name: Upgrade given cask with force option
  community.general.homebrew_cask:
    name: alfred
    state: upgraded
    install_options: force

- name: Upgrade cask with greedy option
  community.general.homebrew_cask:
    name: 1password
    state: upgraded
    greedy: true

- name: Using sudo password for installing cask
  # ansible_become_password must be set in inventory or group_vars; it is not populated by -K
  community.general.homebrew_cask:
    name: wireshark
    state: present
    sudo_password: "{{ ansible_become_password }}"
"""

import os
import re
import shlex
import tempfile

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.text.converters import to_bytes

from ansible_collections.community.general.plugins.module_utils._homebrew import HomebrewValidate
from ansible_collections.community.general.plugins.module_utils._version import LooseVersion


# exceptions -------------------------------------------------------------- {{{
class HomebrewCaskException(Exception):
    pass


# /exceptions ------------------------------------------------------------- }}}


# utils ------------------------------------------------------------------- {{{
def _create_regex_group_complement(s):
    lines = (line.strip() for line in s.split("\n") if line.strip())
    chars = [_f for _f in (line.split("#")[0].strip() for line in lines) if _f]
    group = rf"[^{''.join(chars)}]"
    return re.compile(group)


# /utils ------------------------------------------------------------------ }}}


class HomebrewCask:
    """A class to manage Homebrew casks."""

    # class regexes ------------------------------------------------ {{{
    VALID_CASK_CHARS = r"""
        \w                  # alphanumeric characters (i.e., [a-zA-Z0-9_])
        .                   # dots
        /                   # slash (for taps)
        \-                  # dashes
        @                   # at symbol
        \+                  # plus symbol
    """

    INVALID_CASK_REGEX = _create_regex_group_complement(VALID_CASK_CHARS)
    # /class regexes ----------------------------------------------- }}}

    # class validations -------------------------------------------- {{{
    @classmethod
    def valid_cask(cls, cask):
        """A valid cask is either None or alphanumeric + backslashes."""

        if cask is None:
            return True

        return isinstance(cask, str) and not cls.INVALID_CASK_REGEX.search(cask)

    @classmethod
    def valid_state(cls, state):
        """
        A valid state is one of:
            - installed
            - absent
        """

        if state is None:
            return True
        else:
            return isinstance(state, str) and state.lower() in (
                "installed",
                "absent",
            )

    @classmethod
    def valid_module(cls, module):
        """A valid module is an instance of AnsibleModule."""

        return isinstance(module, AnsibleModule)

    # /class validations ------------------------------------------- }}}

    # class properties --------------------------------------------- {{{
    @property
    def module(self):
        return self._module

    @module.setter
    def module(self, module):
        if not self.valid_module(module):
            self._module = None
            self.failed = True
            self.message = f"Invalid module: {module}."
            raise HomebrewCaskException(self.message)

        else:
            self._module = module
            return module

    @property
    def path(self):
        return self._path

    @path.setter
    def path(self, path):
        if not HomebrewValidate.valid_path(path):
            self._path = []
            self.failed = True
            self.message = f"Invalid path: {path}."
            raise HomebrewCaskException(self.message)

        else:
            if isinstance(path, str):
                self._path = path.split(":")
            else:
                self._path = path

            return path

    @property
    def brew_path(self):
        return self._brew_path

    @brew_path.setter
    def brew_path(self, brew_path):
        if not HomebrewValidate.valid_brew_path(brew_path):
            self._brew_path = None
            self.failed = True
            self.message = f"Invalid brew_path: {brew_path}."
            raise HomebrewCaskException(self.message)

        else:
            self._brew_path = brew_path
            return brew_path

    @property
    def params(self):
        return self._params

    @params.setter
    def params(self, params):
        self._params = self.module.params
        return self._params

    @property
    def current_cask(self):
        return self._current_cask

    @current_cask.setter
    def current_cask(self, cask):
        if not self.valid_cask(cask):
            self._current_cask = None
            self.failed = True
            self.message = f"Invalid cask: {cask}."
            raise HomebrewCaskException(self.message)

        else:
            self._current_cask = cask
            return cask

    @property
    def brew_version(self):
        try:
            return self._brew_version
        except AttributeError:
            return None

    @brew_version.setter
    def brew_version(self, brew_version):
        self._brew_version = brew_version

    # /class properties -------------------------------------------- }}}

    def __init__(
        self,
        module,
        path=path,
        casks=None,
        state=None,
        sudo_password=None,
        update_homebrew=False,
        install_options=None,
        accept_external_apps=False,
        upgrade_all=False,
        greedy=False,
    ):
        if not install_options:
            install_options = list()
        self._setup_status_vars()
        self._setup_instance_vars(
            module=module,
            path=path,
            casks=casks,
            state=state,
            sudo_password=sudo_password,
            update_homebrew=update_homebrew,
            install_options=install_options,
            accept_external_apps=accept_external_apps,
            upgrade_all=upgrade_all,
            greedy=greedy,
        )

        self._prep()

    # prep --------------------------------------------------------- {{{
    def _setup_status_vars(self):
        self.failed = False
        self.changed = False
        self.changed_count = 0
        self.unchanged_count = 0
        self.message = ""

    def _setup_instance_vars(self, **kwargs):
        for key, val in kwargs.items():
            setattr(self, key, val)

    def _prep(self):
        self._prep_brew_path()

    def _prep_brew_path(self):
        if not self.module:
            self.brew_path = None
            self.failed = True
            self.message = "AnsibleModule not set."
            raise HomebrewCaskException(self.message)

        self.brew_path = self.module.get_bin_path(
            "brew",
            required=True,
            opt_dirs=self.path,
        )
        if not self.brew_path:
            self.brew_path = None
            self.failed = True
            self.message = "Unable to locate homebrew executable."
            raise HomebrewCaskException("Unable to locate homebrew executable.")

        return self.brew_path

    def _status(self):
        return (self.failed, self.changed, self.message)

    # /prep -------------------------------------------------------- }}}

    def run(self):
        try:
            self._run()
        except HomebrewCaskException:
            pass

        if not self.failed and (self.changed_count + self.unchanged_count > 1):
            self.message = f"Changed: {self.changed_count}, Unchanged: {self.unchanged_count}"
        (failed, changed, message) = self._status()

        return (failed, changed, message)

    # checks ------------------------------------------------------- {{{
    def _current_cask_is_outdated(self):
        if not self.valid_cask(self.current_cask):
            return False

        if self._brew_cask_command_is_deprecated():
            base_opts = [self.brew_path, "outdated", "--cask"]
        else:
            base_opts = [self.brew_path, "cask", "outdated"]

        cask_is_outdated_command = base_opts + (["--greedy"] if self.greedy else []) + [self.current_cask]

        rc, out, err = self.module.run_command(cask_is_outdated_command)

        return out != ""

    def _current_cask_is_installed(self):
        if not self.valid_cask(self.current_cask):
            self.failed = True
            self.message = f"Invalid cask: {self.current_cask}."
            raise HomebrewCaskException(self.message)

        if self._brew_cask_command_is_deprecated():
            base_opts = [self.brew_path, "list", "--cask"]
        else:
            base_opts = [self.brew_path, "cask", "list"]

        cmd = base_opts + [self.current_cask]
        rc, out, err = self.module.run_command(cmd)

        return rc == 0

    def _get_brew_version(self):
        if self.brew_version:
            return self.brew_version

        cmd = [self.brew_path, "--version"]
        dummy, out, dummy = self.module.run_command(cmd, check_rc=True)

        pattern = r"Homebrew (.*)(\d+\.\d+\.\d+)(-dirty)?"
        rematch = re.search(pattern, out)
        if not rematch:
            self.module.fail_json(msg="Failed to match regex to get brew version", stdout=out)

        prefix, version, dummy = rematch.groups()
        if ">=" in prefix:
            version = "99.0.0"

        self.brew_version = version
        return self.brew_version

    def _brew_cask_command_is_deprecated(self):
        # The `brew cask` replacements were fully available in 2.6.0 (https://brew.sh/2020/12/01/homebrew-2.6.0/)
        return LooseVersion(self._get_brew_version()) >= LooseVersion("2.6.0")

    # /checks ------------------------------------------------------ }}}

    # commands ----------------------------------------------------- {{{
    def _run(self):
        if self.upgrade_all:
            return self._upgrade_all()

        if self.casks:
            if self.state == "installed":
                return self._install_casks()
            elif self.state == "upgraded":
                return self._upgrade_casks()
            elif self.state == "absent":
                return self._uninstall_casks()

        self.failed = True
        self.message = "You must select a cask to install."
        raise HomebrewCaskException(self.message)

    # sudo_password fix ---------------------- {{{
    def _run_command_with_sudo_password(self, cmd):
        rc, out, err = "", "", ""

        with tempfile.NamedTemporaryFile() as sudo_askpass_file:
            sudo_askpass_file.write(to_bytes(f"#!/bin/sh\necho {shlex.quote(self.sudo_password)}\n"))
            sudo_askpass_file.flush()
            os.chmod(sudo_askpass_file.name, 0o700)

            rc, out, err = self.module.run_command(cmd, environ_update={"SUDO_ASKPASS": sudo_askpass_file.name})

        return (rc, out, err)

    # /sudo_password fix --------------------- }}}

    # updated -------------------------------- {{{
    def _update_homebrew(self):
        rc, out, err = self.module.run_command(
            [
                self.brew_path,
                "update",
            ]
        )
        if rc == 0:
            if out and isinstance(out, str):
                already_updated = any(
                    re.search(r"Already up-to-date.", s.strip(), re.IGNORECASE) for s in out.split("\n") if s
                )
                if not already_updated:
                    self.changed = True
                    self.message = "Homebrew updated successfully."
                else:
                    self.message = "Homebrew already up-to-date."

            return True
        else:
            self.failed = True
            self.message = err.strip()
            raise HomebrewCaskException(self.message)

    # /updated ------------------------------- }}}

    # _upgrade_all --------------------------- {{{
    def _upgrade_all(self):
        if self.module.check_mode:
            self.changed = True
            self.message = "Casks would be upgraded."
            raise HomebrewCaskException(self.message)

        if self._brew_cask_command_is_deprecated():
            cmd = [self.brew_path, "upgrade", "--cask"]
        else:
            cmd = [self.brew_path, "cask", "upgrade"]

        if self.greedy:
            cmd = cmd + ["--greedy"]

        rc, out, err = "", "", ""

        if self.sudo_password:
            rc, out, err = self._run_command_with_sudo_password(cmd)
        else:
            rc, out, err = self.module.run_command(cmd)

        if rc == 0:
            # 'brew upgrade --cask' does not output anything if no casks are upgraded
            if not out.strip():
                self.message = "Homebrew casks already upgraded."

            # handle legacy 'brew cask upgrade'
            elif re.search(r"==> No Casks to upgrade", out.strip(), re.IGNORECASE):
                self.message = "Homebrew casks already upgraded."

            else:
                self.changed = True
                self.message = "Homebrew casks upgraded."

            return True
        else:
            self.failed = True
            self.message = err.strip()
            raise HomebrewCaskException(self.message)

    # /_upgrade_all -------------------------- }}}

    # installed ------------------------------ {{{
    def _install_current_cask(self):
        if not self.valid_cask(self.current_cask):
            self.failed = True
            self.message = f"Invalid cask: {self.current_cask}."
            raise HomebrewCaskException(self.message)

        if "--force" not in self.install_options and self._current_cask_is_installed():
            self.unchanged_count += 1
            self.message = f"Cask already installed: {self.current_cask}"
            return True

        if self.module.check_mode:
            self.changed = True
            self.message = f"Cask would be installed: {self.current_cask}"
            raise HomebrewCaskException(self.message)

        if self._brew_cask_command_is_deprecated():
            base_opts = [self.brew_path, "install", "--cask"]
        else:
            base_opts = [self.brew_path, "cask", "install"]

        opts = base_opts + [self.current_cask] + self.install_options

        cmd = [opt for opt in opts if opt]

        rc, out, err = "", "", ""

        if self.sudo_password:
            rc, out, err = self._run_command_with_sudo_password(cmd)
        else:
            rc, out, err = self.module.run_command(cmd)

        if self._current_cask_is_installed():
            self.changed_count += 1
            self.changed = True
            self.message = f"Cask installed: {self.current_cask}"
            return True
        elif self.accept_external_apps and re.search(r"Error: It seems there is already an App at", err):
            self.unchanged_count += 1
            self.message = f"Cask already installed: {self.current_cask}"
            return True
        else:
            self.failed = True
            self.message = err.strip()
            raise HomebrewCaskException(self.message)

    def _install_casks(self):
        for cask in self.casks:
            self.current_cask = cask
            self._install_current_cask()

        return True

    # /installed ----------------------------- }}}

    # upgraded ------------------------------- {{{
    def _upgrade_current_cask(self):
        command = "upgrade"

        if not self.valid_cask(self.current_cask):
            self.failed = True
            self.message = f"Invalid cask: {self.current_cask}."
            raise HomebrewCaskException(self.message)

        if not self._current_cask_is_installed():
            command = "install"

        if self._current_cask_is_installed() and not self._current_cask_is_outdated():
            self.message = f"Cask is already upgraded: {self.current_cask}"
            self.unchanged_count += 1
            return True

        if self.module.check_mode:
            self.changed = True
            self.message = f"Cask would be upgraded: {self.current_cask}"
            raise HomebrewCaskException(self.message)

        if self._brew_cask_command_is_deprecated():
            base_opts = [self.brew_path, command, "--cask"]
        else:
            base_opts = [self.brew_path, "cask", command]

        opts = base_opts + self.install_options + [self.current_cask]

        cmd = [opt for opt in opts if opt]

        rc, out, err = "", "", ""

        if self.sudo_password:
            rc, out, err = self._run_command_with_sudo_password(cmd)
        else:
            rc, out, err = self.module.run_command(cmd)

        if rc == 0:
            self.changed_count += 1
            self.changed = True
            self.message = f"Cask upgraded: {self.current_cask}"
            return True
        else:
            self.failed = True
            self.message = err.strip()
            raise HomebrewCaskException(self.message)

    def _upgrade_casks(self):
        for cask in self.casks:
            self.current_cask = cask
            self._upgrade_current_cask()

        return True

    # /upgraded ------------------------------ }}}

    # uninstalled ---------------------------- {{{
    def _uninstall_current_cask(self):
        if not self.valid_cask(self.current_cask):
            self.failed = True
            self.message = f"Invalid cask: {self.current_cask}."
            raise HomebrewCaskException(self.message)

        if not self._current_cask_is_installed():
            self.unchanged_count += 1
            self.message = f"Cask already uninstalled: {self.current_cask}"
            return True

        if self.module.check_mode:
            self.changed = True
            self.message = f"Cask would be uninstalled: {self.current_cask}"
            raise HomebrewCaskException(self.message)

        if self._brew_cask_command_is_deprecated():
            base_opts = [self.brew_path, "uninstall", "--cask"]
        else:
            base_opts = [self.brew_path, "cask", "uninstall"]

        opts = base_opts + [self.current_cask] + self.install_options

        cmd = [opt for opt in opts if opt]

        rc, out, err = "", "", ""

        if self.sudo_password:
            rc, out, err = self._run_command_with_sudo_password(cmd)
        else:
            rc, out, err = self.module.run_command(cmd)

        if not self._current_cask_is_installed():
            self.changed_count += 1
            self.changed = True
            self.message = f"Cask uninstalled: {self.current_cask}"
            return True
        else:
            self.failed = True
            self.message = err.strip()
            raise HomebrewCaskException(self.message)

    def _uninstall_casks(self):
        for cask in self.casks:
            self.current_cask = cask
            self._uninstall_current_cask()

        return True

    # /uninstalled --------------------------- }}}
    # /commands ---------------------------------------------------- }}}


def main():
    module = AnsibleModule(
        argument_spec=dict(
            name=dict(
                aliases=["pkg", "package", "cask"],
                type="list",
                elements="str",
            ),
            path=dict(
                default="/usr/local/bin:/opt/homebrew/bin",
                type="path",
            ),
            state=dict(
                default="present",
                choices=[
                    "present",
                    "installed",
                    "latest",
                    "upgraded",
                    "absent",
                    "removed",
                    "uninstalled",
                ],
            ),
            sudo_password=dict(
                type="str",
                no_log=True,
            ),
            update_homebrew=dict(
                default=False,
                type="bool",
            ),
            install_options=dict(
                aliases=["options"],
                type="list",
                elements="str",
            ),
            accept_external_apps=dict(
                default=False,
                type="bool",
            ),
            upgrade_all=dict(
                default=False,
                aliases=["upgrade"],
                type="bool",
            ),
            greedy=dict(
                default=False,
                type="bool",
            ),
        ),
        supports_check_mode=True,
    )

    module.run_command_environ_update = dict(LANGUAGE="C", LC_ALL="C")

    p = module.params

    if p["name"]:
        casks = p["name"]
    else:
        casks = None

    path = p["path"]
    if path:
        path = path.split(":")

    state = p["state"]
    if state in ("present", "installed"):
        state = "installed"
    if state in ("latest", "upgraded"):
        state = "upgraded"
    if state in ("absent", "removed", "uninstalled"):
        state = "absent"

    sudo_password = p["sudo_password"]

    update_homebrew = p["update_homebrew"]
    upgrade_all = p["upgrade_all"]
    greedy = p["greedy"]
    p["install_options"] = p["install_options"] or []
    install_options = [f"--{install_option}" for install_option in p["install_options"]]

    accept_external_apps = p["accept_external_apps"]

    brew_cask = HomebrewCask(
        module=module,
        path=path,
        casks=casks,
        state=state,
        sudo_password=sudo_password,
        update_homebrew=update_homebrew,
        install_options=install_options,
        accept_external_apps=accept_external_apps,
        upgrade_all=upgrade_all,
        greedy=greedy,
    )
    (failed, changed, message) = brew_cask.run()
    if failed:
        module.fail_json(msg=message)
    else:
        module.exit_json(changed=changed, msg=message)


if __name__ == "__main__":
    main()
