#!/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
import tempfile
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__)

PROVIDER = "aws"
MODULES_DIR = "modules"
MANIFEST_FILE = "modules.manifest.json"
JF_SERVER_ID = "hcassc-iac"
JF_DOMAIN = "hcassc.jfrog.io"
JF_TF_PROVIDERS_MIRROR_URL = "https://hcassc.jfrog.io/artifactory/api/terraform/iac-tf-providers-virtual/providers/"
JF_PUB_RELEASES_REPO = "pub-releases-jfrog-remote"
JF_TF_MODULES_REPO = "iac-tf-modules-local"

REQUIRED_TOOLS = [
    ("terraform", "version"),
    ("tflint", "--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 load_module_manifest(root: Path) -> dict[str, str]:
    manifest_path = root / MANIFEST_FILE
    if not manifest_path.exists():
        log.error("%s not found at repo root", MANIFEST_FILE)
        sys.exit(1)
    with open(manifest_path) as f:
        data = json.load(f)
    return {module: nms for nms, mod in data.items() for module in mod}


def get_last_published(
    module_name: str, namespace: str, af_repo: str
) -> tuple[str, str] | None:
    try:
        result: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "jf",
                "rt",
                "search",
                f"{af_repo}/{namespace}/{module_name}/{PROVIDER}/*.zip",
                "--server-id",
                JF_SERVER_ID,
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if result.returncode != 0 or not result.stdout.strip():
            return None
        artifacts = json.loads(result.stdout)
        if not artifacts:
            return None
        latest = sorted(
            artifacts,
            key=lambda a: [int(x) for x in Path(a["path"]).stem.split(".")],
        )[-1]
        props = latest.get("props", {})
        commit = props.get("module.commit", [None])[0]
        version = props.get("terraform.version", [None])[0]
        if not commit or not version:
            log.warning(
                "latest artifact for %s missing props - treating as new", module_name
            )
            return None
        return (commit, version)
    except Exception:
        return None


def get_changed_modules(last_sha: str, module_dir: Path) -> bool:
    result: subprocess.CompletedProcess[str] = subprocess.run(
        ["git", "diff", "--name-only", last_sha, "HEAD", "--", str(module_dir)],
        capture_output=True,
        text=True,
    )
    return bool(result.stdout.strip())


def has_changed_modules(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", "--", MODULES_DIR]
    else:
        cmd = ["git", "diff", "--name-only", "HEAD~1", "HEAD", "--", MODULES_DIR]

    result = subprocess.run(cmd, capture_output=True, text=True, cwd=root)
    return bool(result.stdout.strip())


def bump_version(version: str, last_sha: str, module_dir: Path) -> str:
    result: subprocess.CompletedProcess[str] = subprocess.run(
        [
            "git",
            "log",
            f"{last_sha}..HEAD",
            "--pretty=format:%s",
            "--",
            str(module_dir),
        ],
        capture_output=True,
        text=True,
    )
    commits = result.stdout.strip().splitlines()
    major, minor, patch = (int(x) for x in version.split("."))

    for msg in commits:
        if "!" in msg.split(":")[0]:
            return "%d.%d.%d" % (major + 1, 0, 0)

    for msg in commits:
        if msg.startswith("feat"):
            return "%d.%d.%d" % (major, minor + 1, 0)

    return "%d.%d.%d" % (major, minor, patch + 1)


def write_terraformrc() -> Path:
    content = (
        "provider_installation {\n"
        "  network_mirror {\n"
        f'    url = "{JF_TF_PROVIDERS_MIRROR_URL}"\n'
        "  }\n"
        "}\n"
    )
    rc_file = Path(tempfile.mkstemp(suffix=".terraformrc")[1])
    rc_file.write_text(content)
    log.info("terraformrc written: %s", rc_file)
    return rc_file


def validate_module(module_dir: Path) -> bool:
    module_name = module_dir.name

    # terraform fmt check
    fmt: subprocess.CompletedProcess[str] = subprocess.run(
        ["terraform", "fmt", "-check", "-recursive"],
        capture_output=True,
        text=True,
        cwd=module_dir,
    )
    if fmt.returncode != 0:
        log.error(
            "terraform fmt failed in %s - unformatted files detected", module_name
        )
        return False
    log.info("terraform fmt ok: %s", module_name)

    # terraform init
    init: subprocess.CompletedProcess[str] = subprocess.run(
        ["terraform", "init", "-backend=false", "-no-color"],
        capture_output=True,
        text=True,
        cwd=module_dir,
    )
    if init.returncode != 0:
        log.error("terraform init failed in %s: %s", module_name, init.stderr.strip())
        return False
    log.info("terraform init ok: %s", module_name)

    # terraform validate
    validate: subprocess.CompletedProcess[str] = subprocess.run(
        ["terraform", "validate", "-no-color"],
        capture_output=True,
        text=True,
        cwd=module_dir,
    )
    if validate.returncode != 0:
        log.error(
            "terraform validate failed in %s: %s", module_name, validate.stderr.strip()
        )
        return False
    log.info("terraform validate ok: %s", module_name)

    # tflint init
    tflint_init: subprocess.CompletedProcess[str] = subprocess.run(
        ["tflint", "--init"],
        capture_output=True,
        text=True,
        cwd=module_dir,
    )
    if tflint_init.returncode != 0:
        log.error(
            "tflint init failed in %s: %s", module_name, tflint_init.stderr.strip()
        )
        return False
    log.info("tflint init ok: %s", module_name)

    # tflint
    lint: subprocess.CompletedProcess[str] = subprocess.run(
        ["tflint"],
        capture_output=True,
        text=True,
        cwd=module_dir,
    )
    if lint.returncode != 0:
        log.error("tflint failed in %s - lint errors detected", module_name)
        return False
    log.info("tflint ok: %s", module_name)

    return True


def publish_module(
    module_dir: Path,
    namespace: str,
    version: str,
    commit_sha: str,
    af_repo: str,
) -> bool:
    module_name = module_dir.name

    # configure terraform deployment repo for this module
    try:
        tfc: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "jf",
                "tfc",
                f"--repo-deploy={af_repo}",
                f"--server-id-deploy={JF_SERVER_ID}",
            ],
            capture_output=True,
            text=True,
            cwd=module_dir,
        )
        if tfc.returncode != 0:
            log.error("jf tfc failed for %s: %s", module_name, tfc.stderr.strip())
            return False
    except Exception:
        log.error("jf tfc failed for %s: unexpected error", module_name)
        return False

    # publish module
    try:
        publish: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "jf",
                "tf",
                "publish",
                f"--namespace={namespace}",
                f"--provider={PROVIDER}",
                f"--tag={version}",
            ],
            capture_output=True,
            text=True,
            cwd=module_dir,
            timeout=60,
        )
        if publish.returncode != 0:
            log.error(
                "jf tf publish failed for %s: %s",
                module_name,
                publish.stderr.strip(),
            )
            return False
        log.info("module published: %s@%s", module_name, version)
    except subprocess.TimeoutExpired:
        log.error("jf tf publish timed out for %s", module_name)
        return False
    except Exception:
        log.error("jf tf publish failed for %s: unexpected error", module_name)
        return False

    # stamp commit sha as custom property
    try:
        props: subprocess.CompletedProcess[str] = subprocess.run(
            [
                "jf",
                "rt",
                "set-props",
                f"{af_repo}/{namespace}/{module_name}/{PROVIDER}/{version}.zip",
                f"module.commit={commit_sha}",
                "--server-id",
                JF_SERVER_ID,
            ],
            capture_output=True,
            text=True,
            timeout=30,
        )
        if props.returncode != 0:
            log.error(
                "jf set-props failed for %s: %s", module_name, props.stderr.strip()
            )
            return False
        log.info("module.commit stamped: %s", commit_sha[:8])
    except subprocess.TimeoutExpired:
        log.error("jf set-props timed out for %s", module_name)
        return False
    except Exception:
        log.error("jf set-props failed for %s: unexpected error", module_name)
        return False

    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_modules() -> None:
    log.info("validating changed modules")

    root = get_repo_root()
    module_manifest = load_module_manifest(root)
    module_dirs = sorted(set(p.parent for p in (root / MODULES_DIR).rglob("*.tf")))

    if not module_dirs:
        log.info("no modules found in %s", MODULES_DIR)
        sys.exit(0)

    candidates: list[str] = [d.name for d in module_dirs]
    unregistered: list[str] = []
    skipped: list[str] = []
    done: list[str] = []
    failed: list[str] = []

    rc_file = write_terraformrc()
    os.environ["TF_CLI_CONFIG_FILE"] = str(rc_file)
    os.environ["TF_TOKEN_hcassc_jfrog_io"] = os.environ["JFRW_TOKEN"]

    try:
        for module_dir in module_dirs:
            module_name = module_dir.name
            namespace = module_manifest.get(module_name)

            if namespace is None:
                log.warning(
                    "no namespace mapping found for %s - unregistered", module_name
                )
                unregistered.append(module_name)
                continue

            if (module_dir / ".no-publish").exists():
                log.info("skipping module %s - found .no-publish sentinel", module_name)
                skipped.append(module_name)
                continue

            last = get_last_published(module_name, namespace, JF_TF_MODULES_REPO)

            if last is None:
                log.info("no prior publish found for %s - treating as new", module_name)
            else:
                last_sha, _ = last
                if not get_changed_modules(last_sha, module_dir):
                    log.info("no changes detected in %s - skipping", module_name)
                    skipped.append(module_name)
                    continue

            if not validate_module(module_dir):
                failed.append(module_name)
            else:
                done.append(module_name)
    finally:
        try:
            rc_file.unlink(missing_ok=True)
        except OSError:
            log.warning("could not remove temp terraformrc: %s", rc_file)

    # console report
    log.info(
        "validation complete: %s candidates, %s done, %s skipped, %s unregistered, %s failed",
        len(candidates),
        len(done),
        len(skipped),
        len(unregistered),
        len(failed),
    )
    log.info("  candidates   : %s", ", ".join(candidates) or "(none)")
    log.info("  done         : %s", ", ".join(done) or "(none)")
    log.info("  skipped      : %s", ", ".join(skipped) or "(none)")
    log.info("  unregistered : %s", ", ".join(unregistered) 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"- **Candidates ({len(candidates)}):** {', '.join(f'`{m}`' for m in candidates) or '(none)'}\n"
        f"- **Done ({len(done)}):** {', '.join(f'`{m}`' for m in done) or '(none)'}\n"
        f"- **Skipped ({len(skipped)}):** {', '.join(f'`{m}`' for m in skipped) or '(none)'}\n"
        f"- **Unregistered ({len(unregistered)}):** {', '.join(f'`{m}`' for m in unregistered) or '(none)'}\n"
        f"- **Failed ({len(failed)}):** {', '.join(f'`{m}`' for m in failed) or '(none)'}\n"
    )

    if failed:
        body += "\n\nCheck pipeline logs for details."

    post_pr_comment("Modules Validation Report", body)

    if failed:
        sys.exit(1)

    log.info("modules validation passed")


def cmd_publish_modules() -> None:
    log.info("publishing changed modules to Artifactory")

    root = get_repo_root()
    module_manifest = load_module_manifest(root)
    commit_sha = os.environ["BITBUCKET_COMMIT"]
    module_dirs = sorted(set(p.parent for p in (root / MODULES_DIR).rglob("*.tf")))

    if not module_dirs:
        log.info("no modules found in %s", MODULES_DIR)
        sys.exit(0)

    candidates: list[str] = [d.name for d in module_dirs]
    unregistered: list[str] = []
    skipped: list[str] = []
    published: list[str] = []
    failed: list[str] = []

    for module_dir in module_dirs:
        module_name = module_dir.name
        namespace = module_manifest.get(module_name)

        if namespace is None:
            log.warning("no namespace mapping found for %s - unregistered", module_name)
            unregistered.append(module_name)
            continue

        if (module_dir / ".no-publish").exists():
            log.info("skipping module %s - found .no-publish sentinel", module_name)
            skipped.append(module_name)
            continue

        last = get_last_published(module_name, namespace, JF_TF_MODULES_REPO)

        if last is None:
            log.info("no prior publish found for %s - publishing at 0.1.0", module_name)
            version = "0.1.0"
        else:
            last_sha, last_version = last
            if not get_changed_modules(last_sha, module_dir):
                log.info("no changes detected in %s - skipping", module_name)
                skipped.append(module_name)
                continue
            version = bump_version(last_version, last_sha, module_dir)

        if not publish_module(
            module_dir, namespace, version, commit_sha, JF_TF_MODULES_REPO
        ):
            failed.append(module_name)
            continue

        published.append(f"{module_name}@{version}")

    # console report
    log.info(
        "publish complete: %s candidates, %s published, %s skipped, %s unregistered, %s failed",
        len(candidates),
        len(published),
        len(skipped),
        len(unregistered),
        len(failed),
    )
    log.info("  candidates   : %s", ", ".join(candidates) or "(none)")
    log.info("  published    : %s", ", ".join(published) or "(none)")
    log.info("  skipped      : %s", ", ".join(skipped) or "(none)")
    log.info("  unregistered : %s", ", ".join(unregistered) or "(none)")
    log.info("  failed       : %s", ", ".join(failed) or "(none)")

    if failed:
        sys.exit(1)

    log.info("modules published successfully")


# Main entry point -------------------------------------------------------------


def main():
    parser = argparse.ArgumentParser(
        prog="modules-pipeline",
        description="iac-modules 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 modules")
    sub.add_parser("validate-modules", help="fmt, validate and lint changed modules")
    sub.add_parser(
        "publish-modules", help="package and publish changed modules 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-module commands
    if args.command == "run-scan":
        cmd_run_scan()
        sys.exit(0)
    elif args.command == "run-diagnostics":
        cmd_run_diagnostics()
        sys.exit(0)

    # exit early if no modules changed
    root = get_repo_root()
    if not has_changed_modules(root):
        log.info("no modules changed - nothing to publish")
        sys.exit(0)

    # handle module commands
    if args.command == "validate-modules":
        cmd_validate_modules()
    elif args.command == "publish-modules":
        cmd_publish_modules()
    else:
        parser.print_help()
        sys.exit(1)


# Execution entry point --------------------------------------------------------
if __name__ == "__main__":
    main()
