#!/usr/bin/env python3

import argparse
import http.client
import json
import logging
import os
import shutil
import socket
import ssl
import subprocess
import sys
from pathlib import Path

# Pre-Execution setup ---------------------------------------------------------

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)-8s %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
)

log = logging.getLogger(__name__)

POLICIES_DIR = "policies"
JF_SERVER_ID = "hcassc-iac"
JF_DOMAIN = "hcassc.jfrog.io"
JF_PUB_RELEASES_REPO = "pub-releases-jfrog-remote"
JF_TOOLING_REPO = "hca-iac-generic-tooling-local"

REQUIRED_TOOLS = [
    ("opa", "version"),
    ("jf", "--version"),
]

REQUIRED_ENV_VARS = [
    "BITBUCKET_COMMIT",
    "JFRW_TOKEN",
]

# Runtime overrides ------------------------------------------------------------

os.environ["JFROG_CLI_AVOID_NEW_VERSION_WARNING"] = "true"

# Helper functions -------------------------------------------------------------


def get_repo_root() -> Path:
    result: subprocess.CompletedProcess[str] = subprocess.run(
        ["git", "rev-parse", "--show-toplevel"],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        log.error("not a git repository or git not found")
        sys.exit(1)
    return Path(result.stdout.strip())


def setup_jfrog() -> bool:
    # configure jf cli
    result: subprocess.CompletedProcess[str] = subprocess.run(
        [
            "jf",
            "config",
            "add",
            JF_SERVER_ID,
            "--url",
            f"https://{JF_DOMAIN}",
            "--access-token",
            os.environ["JFRW_TOKEN"],
            "--interactive=false",
            "--overwrite=true",
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        log.error("jfrog config failed: %s", result.stderr.strip())
        return False
    log.info("jfrog configured: %s", JF_SERVER_ID)

    # validate connectivity
    ping: subprocess.CompletedProcess[str] = subprocess.run(
        ["jf", "rt", "ping", "--server-id", JF_SERVER_ID],
        capture_output=True,
        text=True,
        timeout=10,
    )
    if ping.returncode != 0:
        log.error("jfrog ping failed: %s", ping.stderr.strip())
        return False
    log.info("jfrog connectivity ok")
    return True


def has_changed_policies(root: Path) -> bool:
    is_pr = os.environ.get("BITBUCKET_PR_ID") is not None

    if is_pr:
        subprocess.run(
            ["git", "fetch", "origin", "main"],
            capture_output=True,
            cwd=root,
        )
        merge_base = subprocess.run(
            ["git", "merge-base", "origin/main", "HEAD"],
            capture_output=True,
            text=True,
            cwd=root,
        ).stdout.strip()
        cmd = ["git", "diff", "--name-only", merge_base, "HEAD", "--", POLICIES_DIR]
    else:
        cmd = ["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", POLICIES_DIR]

    result = subprocess.run(cmd, capture_output=True, text=True, cwd=root)
    return bool(result.stdout.strip())


def check_opa_fmt() -> bool:
    result: subprocess.CompletedProcess[str] = subprocess.run(
        ["opa", "fmt", "--fail", "-l", f"{POLICIES_DIR}/"],
        capture_output=True,
        text=True,
    )
    if result.returncode == 0:
        log.info("opa fmt ok")
        return True
    log.error("opa fmt failed - unformatted files detected")
    for line in result.stdout.strip().splitlines():
        if line:
            log.error("  %s", line)
    return False


def check_opa_syntax() -> bool:
    result: subprocess.CompletedProcess[str] = subprocess.run(
        ["opa", "check", "--strict", f"{POLICIES_DIR}/"],
        capture_output=True,
        text=True,
    )
    if result.returncode == 0:
        log.info("opa check ok")
        return True
    log.error("opa check failed - syntax errors detected")
    for line in result.stderr.strip().splitlines():
        if line:
            log.error("  %s", line)
    return False


def check_policy_catalog() -> bool:
    try:
        result: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "opa",
                "eval",
                "--data",
                f"{POLICIES_DIR}/",
                "--format",
                "raw",
                "data.validate.deny[_].summary",
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
    except subprocess.TimeoutExpired:
        log.error("policy catalog validation timed out")
        return False
    except Exception:
        log.error("policy catalog validation failed: unexpected error")
        return False

    if result.stdout.strip():
        log.error("policy catalog validation failed - violations detected")
        for line in result.stdout.strip().splitlines():
            if line:
                log.error("  %s", line)
        return False
    log.info("policy catalog validation ok")
    return True


def parse_findings(findings: dict[str, object]) -> str:
    # TODO: parse findings and build markdown summary
    secrets: list[dict[str, object]] = findings.get("secrets") or []  # type: ignore
    iac: list[dict[str, object]] = findings.get("iac") or []  # type: ignore
    sast: list[dict[str, object]] = findings.get("sast") or []  # type: ignore

    total = len(secrets) + len(iac) + len(sast)

    if total == 0:
        return "No security findings detected."

    lines = [f"**{total} finding(s) detected.**\n"]
    if secrets:
        lines.append(f"- Secrets: {len(secrets)}")
    if iac:
        lines.append(f"- IaC vulnerabilities: {len(iac)}")
    if sast:
        lines.append(f"- SAST: {len(sast)}")

    lines.append("\nReview pipeline logs for full scan details.")
    return "\n".join(lines)


def post_pr_comment(title: str, body: str) -> bool:
    workspace = os.environ.get("BITBUCKET_WORKSPACE")
    repo = os.environ.get("BITBUCKET_REPO_SLUG")
    pr_id = os.environ.get("BITBUCKET_PR_ID")
    token = os.environ.get("BITBUCKET_BOT_TOKEN")

    if not all([workspace, repo, pr_id, token]):
        log.warning("pr comment skipped - missing bitbucket context")
        log.warning("workspace : %s", workspace or "n/a")
        log.warning("repo      : %s", repo or "n/a")
        log.warning("pr_id     : %s", pr_id or "n/a")
        log.warning("token     : %s", "(set, hidden)" if token else "n/a")
        return False

    payload = json.dumps({"content": {"raw": f"### {title}\n\n{body}"}}).encode()

    conn: http.client.HTTPSConnection | None = None
    try:
        conn = http.client.HTTPSConnection(
            "api.bitbucket.org",
            timeout=15,
            context=ssl.create_default_context(),
        )
        conn.request(
            "POST",
            f"/2.0/repositories/{workspace}/{repo}/pullrequests/{pr_id}/comments",
            body=payload,
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json",
            },
        )
        response = conn.getresponse()
        if response.status == 201:
            log.info("pr comment posted: %s", title)
            return True
        log.error("pr comment failed: http %s", response.status)
        return False
    except Exception:
        log.error("pr comment failed: unexpected error")
        return False
    finally:
        if conn:
            conn.close()


# Command handlers -------------------------------------------------------------


def cmd_run_diagnostics() -> None:
    log.info("running diagnostics")

    # bitbucket context
    log.info("build number  : %s", os.environ.get("BITBUCKET_BUILD_NUMBER", "n/a"))
    log.info("branch        : %s", os.environ.get("BITBUCKET_BRANCH", "n/a"))
    log.info("commit        : %s", os.environ.get("BITBUCKET_COMMIT", "n/a"))
    log.info("runner uuid   : %s", os.environ.get("BITBUCKET_RUNNER_UUID", "n/a"))
    log.info("repo          : %s", os.environ.get("BITBUCKET_REPO_SLUG", "n/a"))

    # runner environment
    log.info("python        : %s", sys.version.splitlines()[0])
    log.info("platform      : %s", sys.platform)
    log.info("hostname      : %s", socket.gethostname())
    log.info("fqdn          : %s", socket.getfqdn())
    log.info("ip address    : %s", socket.gethostbyname(socket.gethostname()))
    log.info("username      : %s", os.environ.get("USER") or os.environ.get("USERNAME"))
    log.info("cwd           : %s", os.getcwd())

    # tool versions
    for t, v in REQUIRED_TOOLS:
        if not shutil.which(t):
            log.error("tool missing: %s", t)
            continue
        result: subprocess.CompletedProcess[str] = subprocess.run(
            [t, v],
            capture_output=True,
            text=True,
            timeout=30,
        )
        lines = (result.stdout or result.stderr).strip().splitlines()
        log.info("tool found: %s", t)
        for line in lines:
            if line:
                log.info("  %s", line)

    log.info("diagnostics complete")


def cmd_run_scan() -> None:
    log.info("running security scan")

    root = get_repo_root()

    env = os.environ.copy()
    env["JFROG_CLI_LOG_LEVEL"] = "ERROR"
    env["JFROG_CLI_RELEASES_REPO"] = f"{JF_SERVER_ID}/{JF_PUB_RELEASES_REPO}"

    result: subprocess.CompletedProcess[str] = subprocess.run(
        [
            "jf",
            "audit",
            "--sca",
            "--sast",
            "--iac",
            "--secrets",
            "--validate-secrets",
            "--format",
            "simple-json",
            "--server-id",
            JF_SERVER_ID,
            "--exclusions",
            "*.git*",
        ],
        capture_output=True,
        text=True,
        cwd=root,
        env=env,
    )

    if not result.stdout.strip():
        log.warning("jf audit returned no output")
        post_pr_comment(
            "Security Scan Warning",
            "The security scan returned no output. Check the pipeline logs for details.",
        )
        return

    try:
        findings: dict[str, object] = json.loads(result.stdout)
    except json.JSONDecodeError:
        log.error("jf audit output could not be parsed")
        post_pr_comment(
            "Security Scan Warning",
            "The security scan output could not be parsed. Check the pipeline logs for details.",
        )
        return

    log.info("security scan completed")
    post_pr_comment("Security Scan Completed", parse_findings(findings))


def cmd_validate_policies() -> None:
    log.info("validating policies")

    checks = {
        "opa fmt": check_opa_fmt(),
        "opa check": check_opa_syntax(),
        "catalog check": check_policy_catalog(),
    }

    passed = [k for k, v in checks.items() if v]
    failed = [k for k, v in checks.items() if not v]

    # console report
    log.info("validation complete: %s passed, %s failed", len(passed), len(failed))
    log.info("  passed : %s", ", ".join(passed) or "(none)")
    log.info("  failed : %s", ", ".join(failed) or "(none)")

    # pr comment
    result = "PASSED" if not failed else "FAILED"
    body = (
        f"**Result: {result}**\n\n"
        f"- **Passed ({len(passed)}):** {', '.join(f'`{c}`' for c in passed) or '(none)'}\n"
        f"- **Failed ({len(failed)}):** {', '.join(f'`{c}`' for c in failed) or '(none)'}\n"
    )

    if failed:
        body += "\n\nCheck pipeline logs for details."

    post_pr_comment("Policies Validation Report", body)

    if failed:
        sys.exit(1)

    log.info("policies validation passed")


def cmd_publish_policies() -> None:
    log.info("publishing policies to Artifactory")

    commit_sha = os.environ["BITBUCKET_COMMIT"]
    versioned_bundle = f"policies-bundle-{commit_sha}.tar.gz"
    latest_bundle = "policies-bundle-latest.tar.gz"

    # build opa bundle
    try:
        build: subprocess.CompletedProcess[str] = subprocess.run(
            ["opa", "build", f"{POLICIES_DIR}/", "-o", versioned_bundle],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if build.returncode != 0:
            log.error("opa build failed: %s", build.stderr.strip())
            sys.exit(1)
        log.info("opa bundle built: %s", versioned_bundle)
    except subprocess.TimeoutExpired:
        log.error("opa build timed out")
        sys.exit(1)
    except Exception:
        log.error("opa build failed: unexpected error")
        sys.exit(1)

    # upload versioned bundle
    try:
        upload: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "jf",
                "rt",
                "upload",
                versioned_bundle,
                f"{JF_TOOLING_REPO}/policies/{versioned_bundle}",
                "--server-id",
                JF_SERVER_ID,
            ],
            capture_output=True,
            text=True,
            timeout=60,
        )
        if upload.returncode != 0:
            log.error("jf upload failed: %s", upload.stderr.strip())
            sys.exit(1)
        log.info("policy bundle uploaded: %s", versioned_bundle)
    except subprocess.TimeoutExpired:
        log.error("jf upload timed out")
        sys.exit(1)
    except Exception:
        log.error("jf upload failed: unexpected error")
        sys.exit(1)

    # copy to latest
    try:
        copy: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "jf",
                "rt",
                "copy",
                f"{JF_TOOLING_REPO}/policies/{versioned_bundle}",
                f"{JF_TOOLING_REPO}/policies/{latest_bundle}",
                "--server-id",
                JF_SERVER_ID,
            ],
            capture_output=True,
            text=True,
            timeout=15,
        )
        if copy.returncode != 0:
            log.error("jf copy failed: %s", copy.stderr.strip())
            sys.exit(1)
        log.info("policy bundle copied to latest: %s", latest_bundle)
    except subprocess.TimeoutExpired:
        log.error("jf copy timed out")
        sys.exit(1)
    except Exception:
        log.error("jf copy failed: unexpected error")
        sys.exit(1)

    log.info("policies published successfully")


# Main entry point -------------------------------------------------------------


def main():
    parser = argparse.ArgumentParser(
        prog="policies-pipeline",
        description="iac-policies pipeline processor",
    )

    sub = parser.add_subparsers(dest="command", metavar="<command>")
    sub.required = True

    sub.add_parser("run-diagnostics", help="run diagnostics on the runner environment")
    sub.add_parser("run-scan", help="run security scan on policies")
    sub.add_parser("validate-policies", help="fmt, check and validate policies")
    sub.add_parser(
        "publish-policies", help="bundle and publish policies to Artifactory"
    )

    args = parser.parse_args()

    # tools check
    missing_tools = [t for t, _ in REQUIRED_TOOLS if not shutil.which(t)]
    if missing_tools:
        for t in missing_tools:
            log.error("tool missing: %s", t)
        sys.exit(1)
    log.info("tools found: %s", ", ".join(t for t, _ in REQUIRED_TOOLS))

    # env check
    missing_vars = [v for v in REQUIRED_ENV_VARS if not os.environ.get(v)]
    if missing_vars:
        for v in missing_vars:
            log.error("missing env var: %s", v)
        sys.exit(1)
    log.info("env vars found: %s", ", ".join(REQUIRED_ENV_VARS))

    # setup jfrog
    if not setup_jfrog():
        sys.exit(1)

    # handle non-policy commands
    if args.command == "run-scan":
        cmd_run_scan()
        sys.exit(0)
    elif args.command == "run-diagnostics":
        cmd_run_diagnostics()
        sys.exit(0)

    # early exit if no changes in policies dir
    root = get_repo_root()
    if not has_changed_policies(root):
        log.info("no changes detected in %s - nothing to publish", POLICIES_DIR)
        sys.exit(0)

    # handle policy commands
    if args.command == "validate-policies":
        cmd_validate_policies()
    elif args.command == "publish-policies":
        cmd_publish_policies()
    else:
        parser.print_help()
        sys.exit(1)


# Execution entry point --------------------------------------------------------
if __name__ == "__main__":
    main()
