#!/usr/bin/env python3
"""Render an adnanh/webhook hooks file for this host from targets.json.

One hook is emitted per repository that this host actually deploys, not one per
environment. That keeps the Gitea side trivial -- a repository gets exactly one
webhook, configured once -- while the branch/environment mapping stays in
targets.json where it can change without anyone touching Gitea.

Secrets never live in this repository. They are read from a host-local file
(default ~/.config/cd-webhook/secrets.env, mode 0600) as:

    CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=<the secret set in Gitea>

A repository with no secret is a hard error: a hook without HMAC validation
would accept a deploy request from anyone who can reach the port.

Usage:
    cd-render-hooks [--host HOST] [--output PATH] [--redact]
"""

from __future__ import annotations

import argparse
import importlib.machinery
import importlib.util
import json
import os
import re
import stat
import sys
from pathlib import Path

BIN_DIR = Path(__file__).resolve().parent
REPO_ROOT = BIN_DIR.parent


def _load_cd_target():
    """Import bin/cd-target, whose hyphenated name blocks a plain import.

    Sharing the module rather than re-implementing the lookup keeps one
    definition of how a (repo, branch, host) triple is matched.
    """
    spec = importlib.util.spec_from_loader(
        "cd_target",
        importlib.machinery.SourceFileLoader("cd_target", str(BIN_DIR / "cd-target")),
    )
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


_cd_target = _load_cd_target()
load = _cd_target.load
pick_host = _cd_target.pick_host
host_matches = _cd_target.host_matches

DEFAULT_CONFIG_DIR = Path(
    os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")
) / "cd-webhook"
DEFAULT_SECRETS = DEFAULT_CONFIG_DIR / "secrets.env"
DEFAULT_OUTPUT = DEFAULT_CONFIG_DIR / "hooks.json"

REDACTED = "<redacted>"


def secret_var(repo: str) -> str:
    """webdev/domaindingo -> CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO"""
    return "CD_WEBHOOK_SECRET_" + re.sub(r"[^A-Za-z0-9]+", "_", repo).upper()


def hook_id(repo: str) -> str:
    """webdev/domaindingo -> webdev-domaindingo (becomes the URL path)."""
    return re.sub(r"[^A-Za-z0-9]+", "-", repo).lower().strip("-")


def load_secrets(path: Path) -> dict[str, str]:
    if not path.exists():
        sys.exit(
            f"cd-render-hooks: no secrets file at {path}\n"
            f"  Create it (mode 0600) with one line per repository, e.g.\n"
            f"    CD_WEBHOOK_SECRET_WEBDEV_DOMAINDINGO=<secret from the Gitea webhook>"
        )

    mode = path.stat().st_mode
    if mode & (stat.S_IRWXG | stat.S_IRWXO):
        sys.exit(
            f"cd-render-hooks: {path} is group/world accessible.\n"
            f"  Run: chmod 600 {path}"
        )

    secrets: dict[str, str] = {}
    for raw in path.read_text().splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        secrets[key.strip()] = value.strip().strip('"').strip("'")
    return secrets


def build_hook(repo: str, branches: list[str], secret: str) -> dict:
    """One hook: HMAC-validated, push-only, restricted to known branches."""
    branch_rules = [
        {
            "match": {
                "type": "value",
                "value": f"refs/heads/{branch}",
                "parameter": {"source": "payload", "name": "ref"},
            }
        }
        for branch in branches
    ]

    return {
        "id": hook_id(repo),
        "execute-command": str(REPO_ROOT / "bin" / "cd-deploy"),
        "command-working-directory": str(REPO_ROOT),
        "http-methods": ["POST"],
        "incoming-payload-content-type": "application/json",

        # The deploy runs detached; Gitea gets an immediate answer and never
        # times out waiting for an image build plus a health check.
        "response-message": f"deploy request accepted for {repo}",
        "include-command-output-in-response": False,

        # A push to a branch we do not deploy is a normal event, not a failure.
        # Returning 200 keeps Gitea's delivery history readable; the daemon log
        # records which rule did not match.
        "trigger-rule-mismatch-http-response-code": 200,

        "pass-arguments-to-command": [
            {"source": "string", "name": "--repo"},
            {"source": "payload", "name": "repository.full_name"},
            {"source": "string", "name": "--ref"},
            {"source": "payload", "name": "ref"},
            {"source": "string", "name": "--sha"},
            {"source": "payload", "name": "after"},
            {"source": "string", "name": "--event"},
            {"source": "header", "name": "X-Gitea-Event"},
        ],

        "trigger-rule": {
            "and": [
                {
                    "match": {
                        "type": "payload-hmac-sha256",
                        "secret": secret,
                        "parameter": {
                            "source": "header",
                            "name": "X-Gitea-Signature",
                        },
                    }
                },
                {
                    "match": {
                        "type": "value",
                        "value": "push",
                        "parameter": {
                            "source": "header",
                            "name": "X-Gitea-Event",
                        },
                    }
                },
                {"or": branch_rules} if len(branch_rules) > 1 else branch_rules[0],
            ]
        },
    }


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--host")
    parser.add_argument("--secrets", type=Path, default=DEFAULT_SECRETS)
    parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
    parser.add_argument(
        "--redact",
        action="store_true",
        help="print to stdout with secrets masked, and write nothing",
    )
    args = parser.parse_args()

    manifest = load()
    host = pick_host(manifest, args.host)
    names = {host.lower(), host.split(".", 1)[0].lower()}

    # repo -> branches, preserving manifest order for a stable diff.
    by_repo: dict[str, list[str]] = {}
    for target in manifest.get("targets", []):
        if not target.get("enabled", True):
            continue
        if not host_matches(target["host"], names):
            continue
        by_repo.setdefault(target["repo"], []).append(target["branch"])

    if not by_repo:
        sys.exit(
            f"cd-render-hooks: no enabled targets for host {host!r}.\n"
            f"  Either this host deploys nothing, or its name does not match "
            f"any 'host' field in targets.json."
        )

    secrets = {} if args.redact else load_secrets(args.secrets)

    hooks = []
    for repo, branches in by_repo.items():
        if args.redact:
            secret = REDACTED
        else:
            var = secret_var(repo)
            secret = secrets.get(var, "")
            if not secret:
                sys.exit(
                    f"cd-render-hooks: {args.secrets} has no value for {var}\n"
                    f"  Every deployed repository needs its Gitea webhook secret here."
                )
        hooks.append(build_hook(repo, branches, secret))

    rendered = json.dumps(hooks, indent=2) + "\n"

    if args.redact:
        print(rendered, end="")
        return 0

    args.output.parent.mkdir(parents=True, exist_ok=True)
    # Create with restrictive permissions before any secret reaches the disk.
    fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w") as handle:
        handle.write(rendered)

    print(f"wrote {args.output} ({len(hooks)} hook(s) for {host})")
    for repo, branches in by_repo.items():
        print(f"  /hooks/{hook_id(repo)}  <-  {repo}  [{', '.join(branches)}]")
    return 0


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