#!/usr/bin/env python3
"""Build, verify, and deliver a bounded production release webhook."""

from __future__ import annotations

import argparse
import base64
import hashlib
import hmac
import json
import os
import re
import socket
import stat
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any


MAX_PAYLOAD_BYTES = 256 * 1024
MAX_COMPARISON_BYTES = 64 * 1024 * 1024
MAX_COMMITS = 120
MAX_COMMIT_MESSAGE_BYTES = 4_096
MIN_COMMIT_MESSAGE_BYTES = 256
MAX_FILES = 40
MAX_HTTP_RESPONSE_BYTES = 64 * 1024
MAX_HTTP_TIMEOUT_SECONDS = 30.0
PRODUCTION_BASE_URL = "https://venture.infra.one"

REPOSITORY_RE = re.compile(r"\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\Z")
VERSION_RE = re.compile(r"\A[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}\Z")
TAG_RE = re.compile(r"\A[0-9A-Za-z][0-9A-Za-z./+_-]{0,127}\Z")
SHA_RE = re.compile(r"\A[0-9a-fA-F]{40,64}\Z")
AUTOMATION_WEBHOOK_PATH_RE = re.compile(
    r"\A/api/webhooks/automations/[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\Z",
    re.IGNORECASE,
)
WEBHOOK_SECRET_RE = re.compile(r"\A[A-Za-z0-9_-]{43}\Z")


class ReleaseWebhookError(Exception):
    """An expected failure whose message is safe for CI logs."""


class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
    """Do not forward a signed request or accept redirected verification."""

    def redirect_request(self, req, fp, code, msg, headers, newurl):  # noqa: ANN001
        return None


def safe_text(value: Any, max_bytes: int) -> tuple[str, bool]:
    if not isinstance(value, str):
        value = ""

    encoded = value.encode("utf-8", errors="replace")
    if len(encoded) <= max_bytes:
        return encoded.decode("utf-8"), False

    truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
    return truncated, True


def safe_optional_text(value: Any, max_bytes: int) -> str | None:
    if not isinstance(value, str) or value == "":
        return None

    text, _truncated = safe_text(value, max_bytes)
    return text or None


def require_match(value: str, pattern: re.Pattern[str], label: str) -> str:
    if not pattern.fullmatch(value):
        raise ReleaseWebhookError(f"{label} is invalid")
    return value


def validate_compare_url(value: str) -> str:
    parsed = urllib.parse.urlsplit(value)
    forbidden = any(character in value for character in ("\n", "\r", "<", ">", "|"))

    if (
        len(value.encode("utf-8")) > 2_048
        or parsed.scheme != "https"
        or parsed.hostname != "github.com"
        or parsed.username is not None
        or parsed.password is not None
        or parsed.fragment
        or forbidden
    ):
        raise ReleaseWebhookError("compare URL is invalid")

    return value


def run_git(repo: str, args: list[str]) -> bytes:
    try:
        with tempfile.TemporaryFile() as stdout_file:
            result = subprocess.run(
                ["git", "-C", repo, *args],
                check=False,
                stdout=stdout_file,
                stderr=subprocess.DEVNULL,
                timeout=30,
            )

            if stdout_file.tell() > MAX_COMPARISON_BYTES:
                raise ReleaseWebhookError("release diff evidence is too large")

            stdout_file.seek(0)
            output = stdout_file.read(MAX_COMPARISON_BYTES + 1)
    except (OSError, subprocess.TimeoutExpired) as error:
        raise ReleaseWebhookError("could not inspect the release diff") from error

    if result.returncode != 0:
        raise ReleaseWebhookError("could not inspect the release diff")

    return output


def resolve_ref(repo: str, ref: str) -> str:
    output = run_git(repo, ["rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"])
    oid = output.decode("ascii", errors="ignore").strip()
    if not SHA_RE.fullmatch(oid):
        raise ReleaseWebhookError("could not resolve a release tag")
    return oid


def decode_git_path(value: bytes) -> str:
    return value.decode("utf-8", errors="replace")


def parse_name_status(raw: bytes) -> dict[str, str]:
    tokens = raw.split(b"\0")
    statuses: dict[str, str] = {}
    index = 0
    status_names = {
        "A": "added",
        "C": "copied",
        "D": "removed",
        "M": "modified",
        "R": "renamed",
        "T": "changed",
        "U": "unmerged",
        "X": "unknown",
        "B": "broken",
    }

    while index < len(tokens) and tokens[index]:
        status_token = tokens[index].decode("ascii", errors="ignore")
        index += 1
        status_code = status_token[:1]

        if status_code in {"R", "C"}:
            if index + 1 >= len(tokens):
                raise ReleaseWebhookError("could not parse the release diff")
            index += 1
            filename = decode_git_path(tokens[index])
            index += 1
        else:
            if index >= len(tokens):
                raise ReleaseWebhookError("could not parse the release diff")
            filename = decode_git_path(tokens[index])
            index += 1

        statuses[filename] = status_names.get(status_code, "unknown")

    return statuses


def parse_numstat(raw: bytes) -> dict[str, tuple[int, int]]:
    tokens = raw.split(b"\0")
    stats: dict[str, tuple[int, int]] = {}
    index = 0

    while index < len(tokens) and tokens[index]:
        fields = tokens[index].split(b"\t", 2)
        index += 1
        if len(fields) != 3:
            raise ReleaseWebhookError("could not parse the release diff")

        additions_raw, deletions_raw, filename_raw = fields
        if filename_raw == b"":
            if index + 1 >= len(tokens):
                raise ReleaseWebhookError("could not parse the release diff")
            index += 1
            filename_raw = tokens[index]
            index += 1

        try:
            additions = 0 if additions_raw == b"-" else int(additions_raw)
            deletions = 0 if deletions_raw == b"-" else int(deletions_raw)
        except ValueError as error:
            raise ReleaseWebhookError("could not parse the release diff") from error

        stats[decode_git_path(filename_raw)] = (additions, deletions)

    return stats


def changed_files(repo: str, previous_tag: str, current_tag: str) -> list[dict[str, Any]]:
    previous_oid = resolve_ref(repo, previous_tag)
    current_oid = resolve_ref(repo, current_tag)
    refs = [previous_oid, current_oid, "--"]

    statuses = parse_name_status(run_git(repo, ["diff", "--name-status", "-z", "--find-renames", *refs]))
    stats = parse_numstat(run_git(repo, ["diff", "--numstat", "-z", "--find-renames", *refs]))

    files = []
    for filename, status in statuses.items():
        additions, deletions = stats.get(filename, (0, 0))
        safe_filename, _truncated = safe_text(filename, 4_096)
        files.append(
            {
                "filename": safe_filename,
                "status": status,
                "additions": additions,
                "deletions": deletions,
            }
        )

    return sorted(
        files,
        key=lambda item: (-(item["additions"] + item["deletions"]), item["filename"]),
    )


def load_comparison_pages(path: str) -> tuple[int, list[dict[str, Any]]]:
    try:
        if path == "-":
            raw_comparison = sys.stdin.buffer.read(MAX_COMPARISON_BYTES + 1)
        else:
            if os.path.getsize(path) > MAX_COMPARISON_BYTES:
                raise ReleaseWebhookError("comparison evidence is too large")
            with open(path, "rb") as comparison_file:
                raw_comparison = comparison_file.read(MAX_COMPARISON_BYTES + 1)

        if len(raw_comparison) > MAX_COMPARISON_BYTES:
            raise ReleaseWebhookError("comparison evidence is too large")
        decoded = json.loads(raw_comparison)
    except ReleaseWebhookError:
        raise
    except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
        raise ReleaseWebhookError("comparison evidence is invalid") from error

    pages = decoded if isinstance(decoded, list) else [decoded]
    if not pages or not all(isinstance(page, dict) for page in pages):
        raise ReleaseWebhookError("comparison evidence is invalid")

    total_commits = 0
    commits: list[dict[str, Any]] = []
    seen_shas: set[str] = set()

    for page in pages:
        page_total = page.get("total_commits")
        if isinstance(page_total, int) and not isinstance(page_total, bool) and page_total >= 0:
            total_commits = max(total_commits, page_total)

        page_commits = page.get("commits")
        if not isinstance(page_commits, list):
            raise ReleaseWebhookError("comparison evidence is invalid")

        for raw_commit in page_commits:
            if not isinstance(raw_commit, dict):
                raise ReleaseWebhookError("comparison evidence is invalid")

            sha, _truncated = safe_text(raw_commit.get("sha"), 64)
            if not SHA_RE.fullmatch(sha) or sha in seen_shas:
                continue
            seen_shas.add(sha)

            commit_data = raw_commit.get("commit")
            commit_data = commit_data if isinstance(commit_data, dict) else {}
            git_author = commit_data.get("author")
            git_author = git_author if isinstance(git_author, dict) else {}
            github_author = raw_commit.get("author")
            github_author = github_author if isinstance(github_author, dict) else {}

            commits.append(
                {
                    "sha": sha,
                    "message": commit_data.get("message"),
                    "author_name": safe_optional_text(git_author.get("name"), 256),
                    "author_login": safe_optional_text(github_author.get("login"), 128),
                }
            )

    return max(total_commits, len(commits)), commits


def encode_payload(payload: dict[str, Any]) -> bytes:
    return json.dumps(
        payload,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
        allow_nan=False,
    ).encode("utf-8")


def release_payload(
    args: argparse.Namespace,
    total_commits: int,
    raw_commits: list[dict[str, Any]],
    all_files: list[dict[str, Any]],
    commit_limit: int,
    message_limit: int,
    file_limit: int,
) -> dict[str, Any]:
    selected_commits = raw_commits[-commit_limit:] if commit_limit else []
    commits = []

    for commit in selected_commits:
        message, message_truncated = safe_text(commit["message"], message_limit)
        commits.append(
            {
                "sha": commit["sha"],
                "message": message,
                "message_truncated": message_truncated,
                "author_name": commit["author_name"],
                "author_login": commit["author_login"],
            }
        )

    files = all_files[:file_limit]
    commits_truncated = total_commits > len(commits) or any(commit["message_truncated"] for commit in commits)

    return {
        "source": "github_actions",
        "environment": "prod",
        "repository": args.repository,
        "previous_version": args.previous_version,
        "current_version": args.current_version,
        "previous_tag": args.previous_tag,
        "current_tag": args.current_tag,
        "compare_url": args.compare_url,
        "total_commits": total_commits,
        "included_commits": len(commits),
        "commits_truncated": commits_truncated,
        "commits": commits,
        "total_files": len(all_files),
        "included_files": len(files),
        "files_truncated": len(all_files) > len(files),
        "files": files,
        # Agent messages are Markdown at rest; the Slack boundary converts
        # these wrappers to mrkdwn immediately before posting.
        "release_header": f"**Release {args.current_version}**\n\n",
        "full_diff_footer": (
            f"\n\n[Full diff: {args.previous_tag} → {args.current_tag}]({args.compare_url})"
        ),
    }


def validate_build_args(args: argparse.Namespace) -> None:
    require_match(args.repository, REPOSITORY_RE, "repository")
    require_match(args.previous_version, VERSION_RE, "previous version")
    require_match(args.current_version, VERSION_RE, "current version")
    require_match(args.previous_tag, TAG_RE, "previous tag")
    require_match(args.current_tag, TAG_RE, "current tag")
    validate_compare_url(args.compare_url)

    expected_compare_url = (
        f"https://github.com/{args.repository}/compare/"
        f"{args.previous_tag}...{args.current_tag}"
    )
    if args.compare_url != expected_compare_url:
        raise ReleaseWebhookError("compare URL is invalid")

    if args.max_bytes <= 0 or args.max_bytes > MAX_PAYLOAD_BYTES:
        raise ReleaseWebhookError("payload byte limit is invalid")


def build_command(args: argparse.Namespace) -> None:
    validate_build_args(args)
    total_commits, raw_commits = load_comparison_pages(args.comparison_pages)
    all_files = changed_files(args.repo, args.previous_tag, args.current_tag)
    commit_limit = min(MAX_COMMITS, len(raw_commits))
    message_limit = MAX_COMMIT_MESSAGE_BYTES
    file_limit = min(MAX_FILES, len(all_files))

    while True:
        payload = release_payload(
            args,
            total_commits,
            raw_commits,
            all_files,
            commit_limit,
            message_limit,
            file_limit,
        )
        encoded = encode_payload(payload)
        if len(encoded) <= args.max_bytes:
            break

        if message_limit > MIN_COMMIT_MESSAGE_BYTES:
            message_limit = max(MIN_COMMIT_MESSAGE_BYTES, message_limit // 2)
        elif commit_limit > 0:
            commit_limit -= 1
        elif file_limit > 0:
            file_limit -= 1
        else:
            raise ReleaseWebhookError("release payload could not fit within the configured byte limit")

    descriptor = None

    try:
        flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
        descriptor = os.open(args.output, flags, 0o600)
        os.fchmod(descriptor, 0o600)
        with os.fdopen(descriptor, "wb") as output_file:
            descriptor = None
            output_file.write(encoded)
    except OSError as error:
        raise ReleaseWebhookError("could not write the release payload") from error
    finally:
        if descriptor is not None:
            os.close(descriptor)

    print(
        "Built bounded release webhook payload "
        f"({len(encoded)} bytes, {payload['included_commits']} commits, {payload['included_files']} files)."
    )


def validate_timeout(value: float) -> float:
    if value <= 0 or value > MAX_HTTP_TIMEOUT_SECONDS:
        raise ReleaseWebhookError("HTTP timeout is invalid")
    return value


def open_without_redirect(request: urllib.request.Request, timeout: float):
    opener = urllib.request.build_opener(NoRedirectHandler())
    return opener.open(request, timeout=timeout)


def fetch_json(url: str, timeout: float) -> dict[str, Any]:
    request = urllib.request.Request(
        url,
        method="GET",
        headers={"Accept": "application/json", "User-Agent": "allocator-one-release-webhook"},
    )

    try:
        with open_without_redirect(request, timeout) as response:
            raw_body = response.read(MAX_HTTP_RESPONSE_BYTES + 1)
    except urllib.error.HTTPError as error:
        raise ReleaseWebhookError(f"production verification failed with HTTP {error.code}") from error
    except (TimeoutError, socket.timeout) as error:
        raise ReleaseWebhookError("production verification timed out") from error
    except urllib.error.URLError as error:
        if isinstance(error.reason, (TimeoutError, socket.timeout)):
            raise ReleaseWebhookError("production verification timed out") from error
        raise ReleaseWebhookError("production verification could not reach the application") from error
    except OSError as error:
        raise ReleaseWebhookError("production verification could not reach the application") from error

    if len(raw_body) > MAX_HTTP_RESPONSE_BYTES:
        raise ReleaseWebhookError("production verification response is too large")

    try:
        decoded = json.loads(raw_body.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as error:
        raise ReleaseWebhookError("production verification response is invalid") from error

    if not isinstance(decoded, dict):
        raise ReleaseWebhookError("production verification response is invalid")
    return decoded


def verify_command(args: argparse.Namespace) -> None:
    timeout = validate_timeout(args.timeout)
    expected_version = require_match(args.expected_version, VERSION_RE, "expected version")
    base_url = args.base_url.rstrip("/")
    validate_production_base_url(base_url)

    if args.attempts < 1 or args.attempts > 30:
        raise ReleaseWebhookError("production verification attempts are invalid")
    if args.retry_delay < 0 or args.retry_delay > 30:
        raise ReleaseWebhookError("production verification retry delay is invalid")

    for attempt in range(1, args.attempts + 1):
        try:
            verify_production_release(base_url, expected_version, timeout)
            print("Production health and exact release version verified.")
            return
        except ReleaseWebhookError:
            if attempt == args.attempts:
                raise
            time.sleep(args.retry_delay)


def verify_production_release(base_url: str, expected_version: str, timeout: float) -> None:

    health = fetch_json(f"{base_url}/api/-/health", timeout)
    version = fetch_json(f"{base_url}/api/-/version", timeout)

    if health.get("status") != "ok":
        raise ReleaseWebhookError("production health check is not healthy")
    if health.get("version") != expected_version or version.get("version") != expected_version:
        raise ReleaseWebhookError("deployed version does not match the release")


def validate_webhook_url(value: str) -> str:
    parsed = parse_url(value, "release webhook URL")
    local_test_url = parsed.scheme in {"http", "https"} and is_loopback_host(parsed.hostname)
    production_url = (
        parsed.scheme == "https"
        and parsed.hostname == "venture.infra.one"
        and parsed.port is None
        and AUTOMATION_WEBHOOK_PATH_RE.fullmatch(parsed.path)
    )

    if (
        not value
        or (not production_url and not local_test_url)
        or not parsed.netloc
        or parsed.username is not None
        or parsed.password is not None
        or parsed.query
        or parsed.fragment
    ):
        raise ReleaseWebhookError("release webhook URL is invalid")

    return value


def send_command(args: argparse.Namespace) -> None:
    timeout = validate_timeout(args.timeout)
    version = require_match(args.version, VERSION_RE, "release version")
    url, secret = release_webhook_config()

    body = read_regular_file(args.payload, MAX_PAYLOAD_BYTES, "release payload")
    validate_payload_version(body, version)

    timestamp = args.timestamp if args.timestamp is not None else str(int(time.time()))
    if not re.fullmatch(r"[0-9]{1,20}", timestamp):
        raise ReleaseWebhookError("release webhook timestamp is invalid")

    idempotency_key = f"allocator-one-prod-v{version}"
    signed = timestamp.encode("ascii") + b"." + body
    digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    signature = f"sha256={digest}"

    request = urllib.request.Request(
        url,
        data=body,
        method="POST",
        headers={
            "Content-Type": "application/json",
            "User-Agent": "allocator-one-release-webhook",
            "X-Infra-One-Timestamp": timestamp,
            "X-Infra-One-Signature": signature,
            "Idempotency-Key": idempotency_key,
        },
    )

    try:
        with open_without_redirect(request, timeout) as response:
            status = response.status
    except urllib.error.HTTPError as error:
        raise ReleaseWebhookError(f"release webhook delivery failed with HTTP {error.code}") from error
    except (TimeoutError, socket.timeout) as error:
        raise ReleaseWebhookError("release webhook delivery timed out") from error
    except urllib.error.URLError as error:
        if isinstance(error.reason, (TimeoutError, socket.timeout)):
            raise ReleaseWebhookError("release webhook delivery timed out") from error
        raise ReleaseWebhookError("release webhook delivery could not reach the endpoint") from error
    except OSError as error:
        raise ReleaseWebhookError("release webhook delivery could not reach the endpoint") from error

    if status < 200 or status >= 300:
        raise ReleaseWebhookError(f"release webhook delivery failed with HTTP {status}")

    print(f"Release webhook accepted with HTTP {status}.")


def validate_payload_version(body: bytes, version: str) -> None:
    try:
        payload = json.loads(body)
    except (UnicodeDecodeError, json.JSONDecodeError) as error:
        raise ReleaseWebhookError("release payload is invalid") from error

    if not isinstance(payload, dict) or payload.get("current_version") != version:
        raise ReleaseWebhookError("release payload version does not match the release")


def check_config_command(_args: argparse.Namespace) -> None:
    release_webhook_config()
    print("Protected release webhook configuration verified.")


def release_webhook_config() -> tuple[str, str]:
    url = validate_webhook_url(os.environ.get("INFRA_ONE_RELEASE_WEBHOOK_URL", ""))
    secret = os.environ.get("INFRA_ONE_RELEASE_WEBHOOK_SECRET", "")
    validate_webhook_secret(secret)

    return url, secret


def is_loopback_host(hostname: str | None) -> bool:
    return hostname in {"localhost", "127.0.0.1", "::1"}


def validate_production_base_url(value: str) -> str:
    parsed = parse_url(value, "production base URL")
    local_test_url = (
        parsed.scheme in {"http", "https"}
        and is_loopback_host(parsed.hostname)
        and parsed.path in {"", "/"}
    )

    if (
        (value != PRODUCTION_BASE_URL and not local_test_url)
        or not parsed.netloc
        or parsed.username is not None
        or parsed.password is not None
        or parsed.query
        or parsed.fragment
    ):
        raise ReleaseWebhookError("production base URL is invalid")

    return value


def validate_webhook_secret(secret: str) -> None:
    try:
        decoded = base64.urlsafe_b64decode(secret + "=") if WEBHOOK_SECRET_RE.fullmatch(secret) else b""
    except (ValueError, UnicodeEncodeError):
        decoded = b""

    if len(decoded) != 32:
        raise ReleaseWebhookError("release webhook secret is invalid")


def parse_url(value: str, label: str) -> urllib.parse.SplitResult:
    try:
        parsed = urllib.parse.urlsplit(value)
        parsed.port
    except ValueError as error:
        raise ReleaseWebhookError(f"{label} is invalid") from error

    return parsed


def read_regular_file(path: str, max_bytes: int, label: str) -> bytes:
    descriptor = None

    try:
        flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
        descriptor = os.open(path, flags)
        file_stat = os.fstat(descriptor)

        if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_size > max_bytes:
            raise ReleaseWebhookError(f"{label} has an invalid byte size")

        with os.fdopen(descriptor, "rb") as input_file:
            descriptor = None
            body = input_file.read(max_bytes + 1)
    except ReleaseWebhookError:
        raise
    except OSError as error:
        raise ReleaseWebhookError(f"could not read the {label}") from error
    finally:
        if descriptor is not None:
            os.close(descriptor)

    if not body or len(body) > max_bytes:
        raise ReleaseWebhookError(f"{label} has an invalid byte size")

    return body


def parser() -> argparse.ArgumentParser:
    cli = argparse.ArgumentParser(description=__doc__)
    commands = cli.add_subparsers(dest="command", required=True)

    check_config = commands.add_parser("check-config", help="validate protected webhook configuration")
    check_config.set_defaults(handler=check_config_command)

    build = commands.add_parser("build", help="build a bounded release payload")
    build.add_argument("--repository", required=True)
    build.add_argument("--previous-version", required=True)
    build.add_argument("--current-version", required=True)
    build.add_argument("--previous-tag", required=True)
    build.add_argument("--current-tag", required=True)
    build.add_argument("--compare-url", required=True)
    build.add_argument("--comparison-pages", required=True)
    build.add_argument("--repo", required=True)
    build.add_argument("--output", required=True)
    build.add_argument("--max-bytes", type=int, default=MAX_PAYLOAD_BYTES)
    build.set_defaults(handler=build_command)

    verify = commands.add_parser("verify", help="verify production health and release version")
    verify.add_argument("--base-url", required=True)
    verify.add_argument("--expected-version", required=True)
    verify.add_argument("--timeout", type=float, default=10.0)
    verify.add_argument("--attempts", type=int, default=1)
    verify.add_argument("--retry-delay", type=float, default=0.0)
    verify.set_defaults(handler=verify_command)

    send = commands.add_parser("send", help="sign and send exact payload bytes")
    send.add_argument("--payload", required=True)
    send.add_argument("--version", required=True)
    send.add_argument("--timestamp")
    send.add_argument("--timeout", type=float, default=15.0)
    send.set_defaults(handler=send_command)

    return cli


def main() -> int:
    args = parser().parse_args()
    try:
        args.handler(args)
        return 0
    except ReleaseWebhookError as error:
        print(f"Release webhook error: {error}", file=sys.stderr)
        return 1
    except Exception:
        print("Release webhook error: operation failed", file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
