#!/usr/bin/python

# Copyright (c) Ansible project
# 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: github_secrets
short_description: Manage GitHub repository or organization secrets
description:
  - Create, update, or delete secrets in a GitHub repository or organization.
author:
  - Thomas Sjögren (@konstruktoid)
version_added: '12.5.0'
requirements:
  - pynacl
extends_documentation_fragment:
  - community.general._attributes
attributes:
  check_mode:
    support: full
  diff_mode:
    support: none
options:
  organization:
    description:
      - The GitHub username or organization name.
    type: str
    required: true
    aliases: ["org", "username"]
  repository:
    description:
      - The name of the repository.
      - If not provided, the secret will be managed at the organization level.
    type: str
    aliases: ["repo"]
  key:
    description:
      - The name of the secret.
    type: str
  value:
    description:
      - The value of the secret. Required when O(state=present).
    type: str
  visibility:
    description:
      - The visibility of the secret when set at the organization level.
      - Required when O(state=present) and O(repository) is not set.
    type: str
    choices: ["all", "private", "selected"]
  state:
    description:
      - The desired state of the secret.
    type: str
    choices: ["present", "absent"]
    default: "present"
  api_url:
    description:
      - The base URL for the GitHub API.
    type: str
    default: "https://api.github.com"
  token:
    description:
      - The GitHub token used for authentication.
    type: str
    required: true
"""

EXAMPLES = r"""
- name: Add Github secret
  community.general.github_secrets:
    token: "{{ lookup('ansible.builtin.env', 'GITHUB_TOKEN') }}"
    repository: "ansible"
    organization: "ansible"
    key: "TEST_SECRET"
    value: "bob"
    state: "present"

- name: Delete Github secret
  community.general.github_secrets:
    token: "{{ lookup('ansible.builtin.env', 'GITHUB_TOKEN') }}"
    repository: "ansible"
    organization: "ansible"
    key: "TEST_SECRET"
    state: "absent"
"""

RETURN = r"""
result:
  description: The result of the module.
  type: dict
  returned: always
  sample: {
    "msg": "OK (2 bytes)",
    "response": "Secret created",
    "status": 201
  }
"""

import json
import typing as t
from http import HTTPStatus

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.urls import fetch_url

from ansible_collections.community.general.plugins.module_utils import _deps as deps

with deps.declare(
    "pynacl",
    reason="pynacl is a required dependency",
    url="https://pypi.org/project/PyNaCl/",
):
    from nacl import encoding, public


def get_public_key(
    module: AnsibleModule,
    api_url: str,
    headers: dict[str, str],
    organization: str,
    repository: str,
) -> tuple[str, str]:
    """Retrieve the GitHub Actions public key used to encrypt secrets."""
    if repository:
        url = f"{api_url}/repos/{organization}/{repository}/actions/secrets/public-key"
    else:
        url = f"{api_url}/orgs/{organization}/actions/secrets/public-key"

    resp, info = fetch_url(module, url, headers=headers)

    if info["status"] != HTTPStatus.OK:
        module.fail_json(msg=f"Failed to get public key: {info}")

    data = json.loads(resp.read())
    return data["key_id"], data["key"]


def encrypt_secret(public_key: str, secret_value: str) -> str:
    """Encrypt a secret value using GitHub's public key."""
    key = public.PublicKey(
        public_key.encode("utf-8"),
        encoding.Base64Encoder(),  # type: ignore [arg-type]
    )
    sealed_box = public.SealedBox(key)
    encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
    return encoding.Base64Encoder.encode(encrypted).decode("utf-8")


def check_secret(
    module: AnsibleModule,
    api_url: str,
    headers: dict[str, str],
    organization: str,
    repository: str,
    key: str,
) -> dict[str, int]:
    url = (
        f"{api_url}/repos/{organization}/{repository}/actions/secrets/{key}"
        if repository
        else f"{api_url}/orgs/{organization}/actions/secrets/{key}"
    )

    resp, info = fetch_url(module, url, headers=headers)

    if info["status"] in (HTTPStatus.OK, HTTPStatus.NOT_FOUND):
        return {"status": info["status"]}
    else:
        module.fail_json(msg=f"Failed to check secret: {info}")


def upsert_secret(
    module: AnsibleModule,
    api_url: str,
    headers: dict[str, str],
    organization: str,
    repository: str,
    key: str,
    encrypted_value: str,
    key_id: str,
) -> dict[str, t.Any]:
    """Create or update a GitHub Actions secret."""
    url = (
        f"{api_url}/repos/{organization}/{repository}/actions/secrets/{key}"
        if repository
        else f"{api_url}/orgs/{organization}/actions/secrets/{key}"
    )

    payload = {
        "encrypted_value": encrypted_value,
        "key_id": key_id,
    }

    if not repository and module.params.get("visibility"):
        payload["visibility"] = module.params["visibility"]

    if module.check_mode:
        secret_present = check_secret(module, api_url, headers, organization, repository, key)
        if secret_present["status"] == HTTPStatus.NOT_FOUND:
            check_mode_msg = "OK (2 bytes)"
            check_mode_status = HTTPStatus.CREATED.value

            info = {
                "msg": check_mode_msg,
                "status": check_mode_status,
            }
        else:
            check_mode_msg = "OK (unknown bytes)"
            check_mode_status = HTTPStatus.NO_CONTENT

            info = {
                "msg": check_mode_msg,
                "status": check_mode_status,
            }
    else:
        resp, info = fetch_url(
            module,
            url,
            headers=headers,
            data=json.dumps(payload).encode("utf-8"),
            method="PUT",
        )

    if info["status"] not in (HTTPStatus.CREATED, HTTPStatus.NO_CONTENT):
        module.fail_json(msg=f"Failed to upsert secret: {info}")

    return info


def delete_secret(
    module: AnsibleModule,
    api_url: str,
    headers: dict[str, str],
    organization: str,
    repository: str,
    key: str,
) -> dict[str, t.Any]:
    """Delete a GitHub Actions secret."""
    url = (
        f"{api_url}/repos/{organization}/{repository}/actions/secrets/{key}"
        if repository
        else f"{api_url}/orgs/{organization}/actions/secrets/{key}"
    )

    if module.check_mode:
        secret_present = check_secret(module, api_url, headers, organization, repository, key)
        info = {
            "msg": (
                "HTTP Error 404: Not Found"
                if secret_present["status"] == HTTPStatus.NOT_FOUND
                else "OK (unknown bytes)"
            ),
            "status": (
                HTTPStatus.NO_CONTENT if secret_present["status"] == HTTPStatus.OK else secret_present["status"]
            ),
        }
    else:
        resp, info = fetch_url(
            module,
            url,
            headers=headers,
            method="DELETE",
        )

    if info["status"] not in (HTTPStatus.NO_CONTENT, HTTPStatus.NOT_FOUND):
        module.fail_json(msg=f"Failed to delete secret: {info}")

    return info


def main() -> None:
    """Ansible module entry point."""
    argument_spec = {
        "organization": {
            "type": "str",
            "aliases": ["org", "username"],
            "required": True,
        },
        "repository": {"type": "str", "aliases": ["repo"]},
        "key": {"type": "str", "no_log": False},
        "value": {"type": "str", "no_log": True},
        "visibility": {"type": "str", "choices": ["all", "private", "selected"]},
        "state": {
            "type": "str",
            "choices": ["present", "absent"],
            "default": "present",
        },
        "api_url": {"type": "str", "default": "https://api.github.com"},
        "token": {"type": "str", "required": True, "no_log": True},
    }

    module = AnsibleModule(
        argument_spec=argument_spec,
        required_if=[("state", "present", ["value"])],
        required_by={"value": "key"},
        supports_check_mode=True,
    )

    deps.validate(module)

    organization: str = module.params["organization"]
    repository: str = module.params["repository"]
    key: str = module.params["key"]
    value: str = module.params["value"]
    visibility: str = module.params.get("visibility")
    state: str = module.params["state"]
    api_url: str = module.params["api_url"]
    token: str = module.params["token"]

    if state == "present" and value is None:
        module.fail_json(
            msg="Invalid parameters",
            details="The 'value' parameter cannot be empty",
            params=module.params,
        )

    if state == "present" and not repository and not visibility:
        module.fail_json(
            msg="Invalid parameters",
            details="When 'state' is 'present' and 'repository' is not set, 'visibility' must be provided",
            params=module.params,
        )

    result: dict[str, t.Any] = {}

    headers = {
        "Accept": "application/vnd.github+json",
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }

    if state == "present":
        key_id, public_key = get_public_key(
            module,
            api_url,
            headers,
            organization,
            repository,
        )

        encrypted_value = encrypt_secret(public_key, value)

        upsert = upsert_secret(
            module,
            api_url,
            headers,
            organization,
            repository,
            key,
            encrypted_value,
            key_id,
        )

        response_msg = "Secret created" if upsert["status"] == HTTPStatus.CREATED else "Secret updated"

        result["changed"] = True
        result.update(
            result={
                "status": upsert["status"],
                "msg": upsert.get("msg"),
                "response": response_msg,
            },
        )

    if state == "absent":
        delete = delete_secret(
            module,
            api_url,
            headers,
            organization,
            repository,
            key,
        )

        if delete["status"] == HTTPStatus.NO_CONTENT:
            result["changed"] = True
            result.update(
                result={
                    "status": delete["status"],
                    "msg": delete.get("msg"),
                    "response": "Secret deleted",
                },
            )

        if delete["status"] == HTTPStatus.NOT_FOUND:
            result["changed"] = False
            result.update(
                result={
                    "status": delete["status"],
                    "msg": delete.get("msg"),
                    "response": "Secret not found",
                },
            )

    module.exit_json(**result)


if __name__ == "__main__":
    main()
