#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (c) 2025 Victor LEFEBVRE <dev@vic1707.xyz>
# 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 absolute_import, division, print_function

__metaclass__ = type


DOCUMENTATION = r"""
module: storagebox_subaccount
short_description: Create, update, or delete a subaccount for a storage box
version_added: 2.4.0
author:
  - Victor LEFEBVRE (@vic1707)
description:
  - Create, update, or delete a subaccount for a storage box.
extends_documentation_fragment:
  - community.hrobot.robot
  - community.hrobot.attributes
  - community.hrobot.attributes.actiongroup_robot

attributes:
  check_mode:
    support: full
  diff_mode:
    support: none
  idempotent:
    support: partial
    details:
      - The Hetzner API does not allow to create subaccounts with specific usernames.
        You can instead use O(comment) to identify accounts by setting O(idempotence=comment),
        that way creation is idempotent.
      - The module is never idempotent if O(password_mode=set-to-random), or if O(password_mode=update-if-provided) and O(password) is specified.
        Set O(password_mode=ignore-if-exists) if you want to provide O(password) on every invocation
        and do not want the module to always change it. Due to how Hetzner's API works, it is not possible
        to query the current password for a subaccount, or check whether a given password is set.

options:
  storagebox_id:
    description:
      - The ID of the storage box to query.
    type: int
    required: true
  password_mode:
    description:
      - Controls how password updates are handled.
      - If C(update-if-provided), the password always updated if provided (default).
      - If C(ignore-if-exists), password is only used during creation.
      - If C(set-to-random), password is reset to a randomly generated one.
      - When a new subaccount is created, the password is set to the specified one if O(password) is provided,
        and a random password is set if O(password) is not provided.
    type: str
    choices: [update-if-provided, ignore-if-exists, set-to-random]
    default: update-if-provided
    required: false

  state:
    description:
      - Desired state of this subaccount.
    choices: [present, absent]
    type: str
    default: present

  username:
    description:
      - Username of the subaccount.
      - Required when using O(idempotence=username) for updates or deletion of a subaccount.
      - If O(idempotence=username) and this is not specified, a new subaccount will always be created.
        If O(idempotence=comment), this option is ignored, as the Hetzner API does not allow to chose or modify the username.
    type: str
    required: false

  password:
    description:
      - Password to use or change.
      - See O(password_mode) for how and when this is used.
      - Will be ignored if O(password_mode=set-to-random).
    type: str
    required: false

  homedirectory:
    description:
      - Home directory of the subaccount.
      - Required only when creating a subaccount (O(state=present)).
    type: str
    required: false

  samba:
    description:
      - Enable or disable Samba.
    type: bool
    required: false

  ssh:
    description:
      - Enable or disable SSH access.
    type: bool
    required: false

  external_reachability:
    description:
      - Enable or disable external reachability (from outside Hetzner's networks).
    type: bool
    required: false

  webdav:
    description:
      - Enable or disable WebDAV.
    type: bool
    required: false

  readonly:
    description:
      - Enable or disable read-only mode.
    type: bool
    required: false

  comment:
    description:
      - A custom comment for the subaccount.
      - Is required when using O(idempotence=comment) for updates or deletion of a subaccount.
    type: str
    required: false

  idempotence:
    description:
      - Select which attribute to use to check subaccount existence.
      - If set to C(username), then subaccounts are identified by their username.
        Note that usernames cannot be specified on creation, so you need to use different
        module arguments for creation and updating.
      - If set to C(comment), then subaccounts are identified by their comment.
        If there already exist more than one subaccount with the given comment, the module will fail.
    type: str
    choices: [username, comment]
    default: username
    required: false

notes:
  - When passwords are autogenerated by the API (by omitting the O(password) field), the resulting password is returned.
"""

EXAMPLES = r"""
---
- name: Create a new subaccount with random password
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    homedirectory: "/backups/project1"
    samba: true
    ssh: true
    webdav: false
    comment: "Backup for Project 1"

- name: Create a subaccount with custom password
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    username: "backup1"
    password: "s3cretPass123"
    homedirectory: "/data/backup1"
    readonly: false
    samba: true
    ssh: false

- name: Update an existing subaccount
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    state: present
    username: "backup1"
    homedirectory: "/data/backup1-updated"
    readonly: true
    comment: "Updated path and readonly mode"

- name: Delete a subaccount
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    state: absent
    username: "backup1"

- name: Change password for a subaccount
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    state: present
    username: "backup1"
    password: "n3wSecur3Pass"

- name: Create subaccount using comment for idempotence
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    homedirectory: "/projects/backup1"
    samba: true
    ssh: true
    webdav: false
    readonly: false
    comment: "Backup1 - Project Foo"
    idempotence: comment

- name: Update subaccount identified by comment
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    homedirectory: "/projects/backup1-updated"
    readonly: true
    comment: "Backup1 - Project Foo"
    idempotence: comment

- name: Update password for subaccount using comment idempotence
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    password: "Sup3rSecur3!"
    comment: "Backup1 - Project Foo"
    idempotence: comment
    password_mode: update-if-provided

- name: Delete subaccount identified by comment
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    state: absent
    comment: "Backup1 - Project Foo"
    idempotence: comment

- name: Use password only during creation
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    password: "InitPass$42"
    homedirectory: "/mnt/init"
    samba: true
    ssh: false
    comment: "Init Subaccount"
    idempotence: comment
    password_mode: ignore-if-exists

- name: Always reset to a random password
  community.hrobot.storagebox_subaccount:
    storagebox_id: 123456
    comment: "Temp Access - CI/CD"
    idempotence: comment
    password_mode: set-to-random
"""

RETURN = r"""
created:
  description: Whether a new subaccount was created.
  type: bool
  returned: success

deleted:
  description: Whether the subaccount was deleted.
  type: bool
  returned: success

updated:
  description: Whether the subaccount's configuration was updated (excluding password changes).
  type: bool
  returned: success

password_updated:
  description: Whether the subaccount's password was updated.
  type: bool
  returned: success

subaccount:
  description: The subaccount object returned by the API.
  type: dict
  returned: if O(state=present)
"""

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.six.moves.urllib.parse import urlencode

from ansible_collections.community.hrobot.plugins.module_utils.robot import (
    BASE_URL,
    ROBOT_DEFAULT_ARGUMENT_SPEC,
    fetch_url_json,
)


def encode_data(data):
    """Converts booleans to lowercase strings and filters out None values."""
    return urlencode(
        {
            key: str(value).lower() if isinstance(value, bool) else value
            for key, value in data.items()
            if value is not None
        }
    )


def create_subaccount(module, storagebox_id, subaccount):
    url = "{0}/storagebox/{1}/subaccount".format(BASE_URL, storagebox_id)
    res, error = fetch_url_json(
        module,
        url,
        method="POST",
        data=encode_data(subaccount),
        headers={"Content-type": "application/x-www-form-urlencoded"},
        accept_errors=[
            "STORAGEBOX_SUBACCOUNT_LIMIT_EXCEEDED",
            "STORAGEBOX_INVALID_PASSWORD",
        ],
        timeout=30000,  # this endpoint is stupidly slow
    )

    if error == "STORAGEBOX_INVALID_PASSWORD":
        module.fail_json(msg="Invalid password (says Hetzner)")
    if error == "STORAGEBOX_SUBACCOUNT_LIMIT_EXCEEDED":
        module.fail_json(msg="Subaccount limit exceeded")

    # Contains all subaccount informations
    # { "subaccount": <data> }
    return res["subaccount"]


def merge_subaccounts_infos(original, updates):
    # None values aren't updated
    result = original.copy()
    for key, value in updates.items():
        if value is not None:
            result[key] = value
    return result


def is_subaccount_updated(before, after):
    for key, value in after.items():
        # Means user didn't provide a value
        # we assume we don't want to update that field
        if value is None:
            continue
        # password aren't considered part of update check
        # due to being a different API call
        if key == "password":
            continue
        if before.get(key) != value:
            return True
    return False


def delete_subaccount(module, storagebox_id, subaccount):
    empty, error = fetch_url_json(
        module,
        "{0}/storagebox/{1}/subaccount/{2}".format(
            BASE_URL, storagebox_id, subaccount["username"]
        ),
        method="DELETE",
        allow_empty_result=True,
        headers={"Content-type": "application/x-www-form-urlencoded"},
    )


def update_subaccount(module, storagebox_id, subaccount):
    empty, error = fetch_url_json(
        module,
        "{0}/storagebox/{1}/subaccount/{2}".format(
            BASE_URL, storagebox_id, subaccount["username"]
        ),
        method="PUT",
        data=encode_data({key: value for key, value in subaccount.items() if key != "password"}),
        headers={"Content-type": "application/x-www-form-urlencoded"},
        allow_empty_result=True,
        timeout=30000,  # this endpoint is stupidly slow
    )


def update_subaccount_password(module, storagebox_id, subaccount):
    new_password, error = fetch_url_json(
        module,
        "{0}/storagebox/{1}/subaccount/{2}/password".format(
            BASE_URL, storagebox_id, subaccount["username"]
        ),
        method="POST",
        data=encode_data({"password": subaccount["password"]}),
        headers={"Content-type": "application/x-www-form-urlencoded"},
        accept_errors=[
            "STORAGEBOX_INVALID_PASSWORD",
        ],
        timeout=30000,  # this endpoint is stupidly slow
    )
    if error == "STORAGEBOX_INVALID_PASSWORD":
        module.fail_json(msg="Invalid password (says Hetzner)")

    # { "password": <password> }
    return new_password["password"]


def get_subaccounts(module, storagebox_id):
    url = "{0}/storagebox/{1}/subaccount".format(BASE_URL, storagebox_id)
    result, error = fetch_url_json(module, url, accept_errors=["STORAGEBOX_NOT_FOUND"])
    if error:
        module.fail_json(
            msg="Storagebox with ID {0} does not exist".format(storagebox_id)
        )
    # Hetzner's response [ { "subaccount": <data> }, ... ]
    return [item["subaccount"] for item in result]


def main():
    argument_spec = dict(
        storagebox_id=dict(type="int", required=True),
        password_mode=dict(
            type="str",
            no_log=True,
            choices=["update-if-provided", "ignore-if-exists", "set-to-random"],
            default="update-if-provided",
        ),
        state=dict(type="str", choices=["present", "absent"], default="present"),
        username=dict(type="str"),
        password=dict(type="str", no_log=True),
        homedirectory=dict(type="str"),
        samba=dict(type="bool"),
        ssh=dict(type="bool"),
        external_reachability=dict(type="bool"),
        webdav=dict(type="bool"),
        readonly=dict(type="bool"),
        comment=dict(type="str"),
        idempotence=dict(type="str", choices=["username", "comment"], default="username"),
    )
    argument_spec.update(ROBOT_DEFAULT_ARGUMENT_SPEC)
    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,
    )

    check_mode = module.check_mode
    storagebox_id = module.params["storagebox_id"]
    password_mode = module.params["password_mode"]
    state = module.params["state"]
    idempotence = module.params["idempotence"]
    subaccount = {
        "username": module.params["username"],
        "password": module.params["password"],
        "homedirectory": module.params["homedirectory"],
        "samba": module.params["samba"],
        "ssh": module.params["ssh"],
        "external_reachability": module.params["external_reachability"],
        "webdav": module.params["webdav"],
        "readonly": module.params["readonly"],
        "comment": module.params["comment"],
    }
    account_identifier = subaccount[idempotence]

    existing_subaccounts = get_subaccounts(module, storagebox_id)

    matches = [
        sa for sa in existing_subaccounts
        if sa[idempotence] == account_identifier
    ]
    if len(matches) > 1:
        module.fail_json(msg="More than one subaccount matched the idempotence criteria.")

    existing = matches[0] if matches else None

    created = deleted = updated = password_updated = False

    if state == "absent":
        if existing:
            if not check_mode:
                delete_subaccount(module, storagebox_id, existing)
            deleted = True
    elif state == "present" and existing:
        # Set the found username in case user used comment as idempotence
        subaccount["username"] = existing["username"]

        if (
            password_mode == "set-to-random" or
            (password_mode == "update-if-provided" and subaccount["password"])
        ):
            if password_mode == "set-to-random":
                subaccount["password"] = None
            if not check_mode:
                new_password = update_subaccount_password(module, storagebox_id, subaccount)
                subaccount["password"] = new_password
            password_updated = True

        if is_subaccount_updated(existing, subaccount):
            if not check_mode:
                update_subaccount(module, storagebox_id, subaccount)
            updated = True
    else:  # state 'present' without pre-existing account
        if not subaccount["homedirectory"]:
            module.fail_json(msg="homedirectory is required when creating a new subaccount")
        if password_mode == "set-to-random":
            subaccount["password"] = None

        del subaccount["username"]  # username cannot be choosen
        if not check_mode:
            # not necessary, allows us to get additional infos (created time etc...)
            existing = create_subaccount(module, storagebox_id, subaccount)
        created = True

    return_data = merge_subaccounts_infos(existing or {}, subaccount)

    module.exit_json(
        changed=any([created, deleted, updated, password_updated]),
        created=created,
        deleted=deleted,
        updated=updated,
        password_updated=password_updated,
        subaccount=return_data if state != "absent" else None,
    )


if __name__ == "__main__":  # pragma: no cover
    main()  # pragma: no cover
