#!/usr/bin/env python3
"""
depush — deploy a versioned codebase directory to S3/MinIO, SSH, or a local directory.

All options can be set via CLI flags, environment variables, or a YAML config file.
Precedence (highest to lowest): CLI flag > environment variable > YAML config > default.

Usage (local):
  depush --target s3 --prefix mylib
  depush --target ssh --prefix mylib
  depush --target local --prefix mylib --local-dest /srv/releases
  depush --config deploy-config.yaml

Usage (Docker):
  docker run --rm -v ./codebase:/codebase \\
    -e DEPUSH_DEPLOY_TARGET=s3 -e DEPUSH_DEPLOY_PREFIX=mylib \\
    -e DEPUSH_S3_BUCKET=deployments -e DEPUSH_S3_ENDPOINT=http://minio:9000 \\
    -e DEPUSH_S3_ACCESS_KEY=admin -e DEPUSH_S3_SECRET_KEY=admin \\
    tfdeploy-deploy

Environment variables:
  General:
    DEPUSH_DEPLOY_TARGET          Deployment target: s3, ssh, or local (required)
    DEPUSH_DEPLOY_PREFIX          Path prefix, e.g. 'mylib' -> 'mylib/{version}/' (required)
    DEPUSH_DEPLOY_CODEBASE_DIR    Path to codebase directory containing a 'version' file (default: .)
    DEPUSH_VERSION_OVERRIDE        Override the deployment version; the version file is not required when set
    DEPUSH_DEPLOY_DRY_RUN         Set to '1', 'true', or 'yes' to preview without uploading
    DEPUSH_DEPLOY_CONFIG          Path to a YAML config file (default: depush.yaml in CWD if present)

  Local directory:
    DEPUSH_DEPLOY_LOCAL_DEST      Root destination directory for local deployments (default: ./dist)

  S3 / MinIO:
    DEPUSH_S3_BUCKET              Bucket name (required for s3 target)
    DEPUSH_S3_ENDPOINT            Custom endpoint URL for MinIO, e.g. http://localhost:9000
    DEPUSH_S3_REGION              AWS region (default: us-east-1)  (alias: AWS_DEFAULT_REGION)
    DEPUSH_S3_ACCESS_KEY          Access key / MinIO username  (alias: AWS_ACCESS_KEY_ID)
    DEPUSH_S3_SECRET_KEY          Secret key / MinIO password  (alias: AWS_SECRET_ACCESS_KEY)

  SSH:
    DEPUSH_SSH_HOST               SSH server hostname or IP (required for ssh target)
    DEPUSH_SSH_PORT               SSH port (default: 22)
    DEPUSH_SSH_USER               SSH username (default: admin)
    DEPUSH_SSH_PASSWORD           SSH password (leave unset to use key-based auth)
    DEPUSH_SSH_KEY_FILE           Path to private key file
    DEPUSH_SSH_DEPLOY_ROOT        Absolute path on the remote server (default: /deployments)
"""

import argparse
import os
import shutil
import subprocess
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# Config resolution constants
# ---------------------------------------------------------------------------

# Hardcoded fallback defaults
DEFAULTS = {
    "codebase_dir": ".",
    "dry_run": False,
    "local_dest": "./dist",
    "s3_region": "us-east-1",
    "ssh_port": 22,
    "ssh_user": "admin",
    "ssh_deploy_root": "/deployments",
}

# Mapping from argparse dest -> environment variable name
ENV_MAP = {
    "target": "DEPUSH_DEPLOY_TARGET",
    "prefix": "DEPUSH_DEPLOY_PREFIX",
    "codebase_dir": "DEPUSH_DEPLOY_CODEBASE_DIR",
    "version_override": "DEPUSH_VERSION_OVERRIDE",
    "dry_run": "DEPUSH_DEPLOY_DRY_RUN",
    "local_dest": "DEPUSH_DEPLOY_LOCAL_DEST",
    "s3_bucket": "DEPUSH_S3_BUCKET",
    "s3_endpoint": "DEPUSH_S3_ENDPOINT",
    "s3_region": "DEPUSH_S3_REGION",
    "s3_profile": "DEPUSH_S3_PROFILE",
    "s3_access_key": "DEPUSH_S3_ACCESS_KEY",
    "s3_secret_key": "DEPUSH_S3_SECRET_KEY",
    "ssh_host": "DEPUSH_SSH_HOST",
    "ssh_port": "DEPUSH_SSH_PORT",
    "ssh_user": "DEPUSH_SSH_USER",
    "ssh_password": "DEPUSH_SSH_PASSWORD",
    "ssh_key_file": "DEPUSH_SSH_KEY_FILE",
    "ssh_deploy_root": "DEPUSH_SSH_DEPLOY_ROOT",
}


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def load_yaml_config(path):
    """Load and flatten a YAML config file into a flat dict matching argparse dests."""
    try:
        import yaml
    except ImportError:
        sys.exit("Error: PyYAML is not installed. Run: pip install pyyaml")

    config_path = Path(path)
    if not config_path.exists():
        sys.exit(f"Error: config file not found: {config_path}")

    with config_path.open() as f:
        raw = yaml.safe_load(f) or {}

    flat = {}

    # Top-level general keys
    for key in ("target", "prefix", "codebase_dir", "version_override", "dry_run"):
        if key in raw:
            flat[key] = raw[key]

    # Nested sections -> flattened with section prefix
    section_map = {
        "local": {"dest": "local_dest"},
        "s3": {
            "bucket": "s3_bucket",
            "endpoint": "s3_endpoint",
            "region": "s3_region",
            "profile": "s3_profile",
            "access_key": "s3_access_key",
            "secret_key": "s3_secret_key",
        },
        "ssh": {
            "host": "ssh_host",
            "port": "ssh_port",
            "user": "ssh_user",
            "password": "ssh_password",
            "key_file": "ssh_key_file",
            "deploy_root": "ssh_deploy_root",
        },
    }
    for section, mapping in section_map.items():
        if section in raw and isinstance(raw[section], dict):
            for yaml_key, dest_key in mapping.items():
                if yaml_key in raw[section]:
                    flat[dest_key] = raw[section][yaml_key]

    return flat


def resolve_defaults(yaml_cfg):
    """
    Build a merged defaults dict with precedence:
    env var > YAML config > hardcoded default.
    Returns a dict suitable for parser.set_defaults().
    """
    merged = dict(DEFAULTS)

    # Layer in YAML values
    for key, value in yaml_cfg.items():
        merged[key] = value

    # Layer in environment variables (highest priority before CLI)
    for dest, env_var in ENV_MAP.items():
        raw = os.environ.get(env_var)
        if raw is None:
            continue
        # Type coercion for known numeric/bool fields
        if dest == "ssh_port":
            try:
                merged[dest] = int(raw)
            except ValueError:
                sys.exit(f"Error: {env_var} must be an integer, got: {raw!r}")
        elif dest == "dry_run":
            merged[dest] = raw.lower() in ("1", "true", "yes")
        else:
            merged[dest] = raw

    # AWS_PROFILE is a widely-used standard env var; use it as a fallback for
    # s3_profile when neither DEPUSH_S3_PROFILE nor a YAML/CLI value was provided.
    if "s3_profile" not in merged:
        aws_profile = os.environ.get("AWS_PROFILE")
        if aws_profile:
            merged["s3_profile"] = aws_profile

    # AWS_DEFAULT_REGION is the standard AWS env var; use it as a fallback when
    # DEPUSH_S3_REGION is not set via env var or YAML config (only overrides the hardcoded default).
    if not os.environ.get("DEPUSH_S3_REGION") and "s3_region" not in yaml_cfg:
        val = os.environ.get("AWS_DEFAULT_REGION")
        if val:
            merged["s3_region"] = val

    # AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY are the standard AWS env vars;
    # use them as fallbacks when DEPUSH_S3_ACCESS_KEY / DEPUSH_S3_SECRET_KEY are not set.
    if "s3_access_key" not in merged:
        val = os.environ.get("AWS_ACCESS_KEY_ID")
        if val:
            merged["s3_access_key"] = val
    if "s3_secret_key" not in merged:
        val = os.environ.get("AWS_SECRET_ACCESS_KEY")
        if val:
            merged["s3_secret_key"] = val

    return merged


def read_version(codebase_dir):
    version_file = codebase_dir / "version"
    if not version_file.exists():
        sys.exit(f"Error: version file not found at {version_file}")
    return version_file.read_text().strip()


def load_ignore_spec(codebase_dir):
    """Load .depushignore from codebase_dir and return a pathspec matcher, or None."""
    ignore_file = codebase_dir / ".depushignore"
    if not ignore_file.exists():
        return None
    try:
        import pathspec
    except ImportError:
        sys.exit("Error: pathspec is not installed. Run: pip install pathspec")
    lines = ignore_file.read_text().splitlines()
    return pathspec.PathSpec.from_lines("gitignore", lines)


def collect_files(codebase_dir, ignore_spec=None):
    """Return all files under codebase_dir, excluding .git, depush.yaml, .depushignore, and any .depushignore patterns."""
    excluded = {codebase_dir / "depush.yaml", codebase_dir / ".depushignore"}
    files = []
    for p in codebase_dir.rglob("*"):
        if not p.is_file():
            continue
        if ".git" in p.parts:
            continue
        if p in excluded:
            continue
        if ignore_spec and ignore_spec.match_file(str(p.relative_to(codebase_dir))):
            continue
        files.append(p)
    return sorted(files)


# ---------------------------------------------------------------------------
# Local directory deployment
# ---------------------------------------------------------------------------


def deploy_local(args, codebase_dir, deploy_path):
    dest = Path(args.local_dest) / deploy_path
    ignore_spec = load_ignore_spec(codebase_dir)

    files = collect_files(codebase_dir, ignore_spec)
    if not files:
        sys.exit("Error: no files found in codebase directory")

    for file_path in files:
        relative = file_path.relative_to(codebase_dir)
        target = dest / relative
        if args.dry_run:
            print(f"  [dry-run] {target}")
        else:
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(file_path, target)
            print(f"  {target}")

    # Delete files at destination that no longer exist in codebase
    codebase_relatives = {f.relative_to(codebase_dir) for f in files}
    deleted = 0
    if dest.exists():
        for existing in sorted(dest.rglob("*")):
            if not existing.is_file():
                continue
            rel = existing.relative_to(dest)
            if rel in codebase_relatives:
                continue
            if ignore_spec and ignore_spec.match_file(str(rel)):
                continue
            deleted += 1
            if args.dry_run:
                print(f"  [dry-run] delete {existing}")
            else:
                existing.unlink()
                print(f"  deleted {existing}")

    tag = "[dry-run] " if args.dry_run else ""
    suffix = f", {deleted} deleted" if deleted else ""
    print(f"\n{tag}Deployed {len(files)} file(s) to {dest}/{suffix}")


# ---------------------------------------------------------------------------
# S3 / MinIO deployment
# ---------------------------------------------------------------------------


def deploy_s3(args, codebase_dir, deploy_path):
    try:
        import boto3
        from botocore.client import Config
    except ImportError:
        sys.exit("Error: boto3 is not installed. Run: pip install boto3")

    ignore_spec = load_ignore_spec(codebase_dir)

    session = boto3.Session(
        profile_name=args.s3_profile or None,
        aws_access_key_id=args.s3_access_key or None,
        aws_secret_access_key=args.s3_secret_key or None,
        region_name=args.s3_region,
    )
    client_kwargs = {}
    if args.s3_endpoint:
        client_kwargs["endpoint_url"] = args.s3_endpoint
        client_kwargs["config"] = Config(signature_version="s3v4")

    s3 = session.client("s3", **client_kwargs)

    files = collect_files(codebase_dir, ignore_spec)
    if not files:
        sys.exit("Error: no files found in codebase directory")

    for file_path in files:
        relative = file_path.relative_to(codebase_dir)
        key = f"{deploy_path}/{relative}"
        if args.dry_run:
            print(f"  [dry-run] s3://{args.s3_bucket}/{key}")
        else:
            print(f"  s3://{args.s3_bucket}/{key}")
            s3.upload_file(str(file_path), args.s3_bucket, key)

    # Delete objects that no longer exist in codebase
    expected_keys = {f"{deploy_path}/{f.relative_to(codebase_dir)}" for f in files}
    deleted = 0
    paginator = s3.get_paginator("list_objects_v2")
    for page in paginator.paginate(Bucket=args.s3_bucket, Prefix=f"{deploy_path}/"):
        for obj in page.get("Contents", []):
            if obj["Key"] in expected_keys:
                continue
            rel_key = obj["Key"][len(deploy_path) + 1 :]
            if ignore_spec and ignore_spec.match_file(rel_key):
                continue
            deleted += 1
            if args.dry_run:
                print(f"  [dry-run] delete s3://{args.s3_bucket}/{obj['Key']}")
            else:
                s3.delete_object(Bucket=args.s3_bucket, Key=obj["Key"])
                print(f"  deleted s3://{args.s3_bucket}/{obj['Key']}")

    tag = "[dry-run] " if args.dry_run else ""
    suffix = f", {deleted} deleted" if deleted else ""
    print(
        f"\n{tag}Deployed {len(files)} file(s) to s3://{args.s3_bucket}/{deploy_path}/{suffix}"
    )


# ---------------------------------------------------------------------------
# SSH / remote filesystem deployment
# ---------------------------------------------------------------------------


def deploy_ssh(args, codebase_dir, deploy_path):
    ignore_spec = load_ignore_spec(codebase_dir)
    remote_root = f"{args.ssh_deploy_root}/{deploy_path}"

    if args.dry_run:
        files = collect_files(codebase_dir, ignore_spec)
        for f in files:
            rel = f.relative_to(codebase_dir)
            print(f"  [dry-run] {args.ssh_user}@{args.ssh_host}:{remote_root}/{rel}")
        print(f"\n[dry-run] Would deploy {len(files)} file(s) to {remote_root}/")
        print(
            "[dry-run] Stale remote files would also be deleted (requires connection to determine)"
        )
        return

    if shutil.which("rsync") and not args.ssh_password:
        _deploy_ssh_rsync(args, codebase_dir, remote_root, ignore_spec)
    else:
        _deploy_ssh_paramiko(args, codebase_dir, remote_root, ignore_spec)


def _deploy_ssh_rsync(args, codebase_dir, remote_root, ignore_spec):
    ssh_opts = f"ssh -p {args.ssh_port}"
    if args.ssh_key_file:
        ssh_opts += f" -i {args.ssh_key_file}"

    cmd = [
        "rsync",
        "--archive",
        "--compress",
        "--delete",
        "--verbose",
        "-e",
        ssh_opts,
        "--exclude=depush.yaml",
        "--exclude=.depushignore",
    ]

    ignore_file = codebase_dir / ".depushignore"
    if ignore_file.exists():
        cmd += [f"--exclude-from={ignore_file}"]

    # Source must end with / to sync contents into the destination directory
    cmd.append(str(codebase_dir) + "/")
    cmd.append(f"{args.ssh_user}@{args.ssh_host}:{remote_root}/")

    result = subprocess.run(cmd)
    if result.returncode != 0:
        sys.exit(f"Error: rsync failed with exit code {result.returncode}")


def _deploy_ssh_paramiko(args, codebase_dir, remote_root, ignore_spec):
    try:
        import paramiko
    except ImportError:
        sys.exit("Error: paramiko is not installed. Run: pip install paramiko")

    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    connect_kwargs = {
        "hostname": args.ssh_host,
        "port": args.ssh_port,
        "username": args.ssh_user,
    }
    if args.ssh_password:
        connect_kwargs["password"] = args.ssh_password
    elif args.ssh_key_file:
        connect_kwargs["key_filename"] = args.ssh_key_file

    try:
        client.connect(**connect_kwargs)
    except Exception as e:
        sys.exit(f"Error: SSH connection failed: {e}")

    def remote_exec(cmd):
        _, stdout, stderr = client.exec_command(cmd)
        exit_code = stdout.channel.recv_exit_status()
        if exit_code != 0:
            sys.exit(
                f"Error: remote command failed ({exit_code}): {cmd}\n{stderr.read().decode()}"
            )

    sftp = client.open_sftp()

    remote_exec(f"mkdir -p '{remote_root}'")

    files = collect_files(codebase_dir, ignore_spec)
    for file_path in files:
        relative = file_path.relative_to(codebase_dir)
        remote_file = f"{remote_root}/{relative}"
        remote_dir = str(Path(remote_file).parent)
        remote_exec(f"mkdir -p '{remote_dir}'")
        print(f"  {args.ssh_user}@{args.ssh_host}:{remote_file}")
        sftp.put(str(file_path), remote_file)

    # Delete remote files that no longer exist in codebase
    codebase_relatives = {str(f.relative_to(codebase_dir)) for f in files}
    expected_remotes = {f"{remote_root}/{rel}" for rel in codebase_relatives}
    _, ls_out, _ = client.exec_command(f"find '{remote_root}' -type f")
    remote_files = set(ls_out.read().decode().splitlines())
    deleted = 0
    for stale in sorted(remote_files - expected_remotes):
        rel_path = stale[len(remote_root) + 1 :]
        if ignore_spec and ignore_spec.match_file(rel_path):
            continue
        deleted += 1
        remote_exec(f"rm -f '{stale}'")
        print(f"  deleted {args.ssh_user}@{args.ssh_host}:{stale}")

    sftp.close()
    client.close()
    suffix = f", {deleted} deleted" if deleted else ""
    print(
        f"\nDeployed {len(files)} file(s) to {args.ssh_user}@{args.ssh_host}:{remote_root}/{suffix}"
    )


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def build_parser():
    parser = argparse.ArgumentParser(
        description="Deploy a versioned codebase directory to S3/MinIO, SSH, or a local directory.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )

    parser.add_argument(
        "--config",
        default=os.environ.get("DEPUSH_DEPLOY_CONFIG"),
        metavar="FILE",
        help="Path to a YAML config file  [env: DEPUSH_DEPLOY_CONFIG]",
    )
    parser.add_argument(
        "--target",
        choices=["s3", "ssh", "local"],
        help="Deployment target (required)  [env: DEPUSH_DEPLOY_TARGET]",
    )
    parser.add_argument(
        "--prefix",
        help="Path prefix, e.g. 'mylib' -> 'mylib/{version}/'  [env: DEPUSH_DEPLOY_PREFIX]",
    )
    parser.add_argument(
        "--codebase-dir",
        dest="codebase_dir",
        help="Path to codebase directory containing a 'version' file (default: .)  [env: DEPUSH_DEPLOY_CODEBASE_DIR]",
    )
    parser.add_argument(
        "--version-override",
        dest="version_override",
        help="Override the deployment version; the version file is not required when set  [env: DEPUSH_VERSION_OVERRIDE]",
    )
    parser.add_argument(
        "--dry-run",
        dest="dry_run",
        action="store_true",
        default=None,
        help="Print what would be deployed without uploading  [env: DEPUSH_DEPLOY_DRY_RUN]",
    )

    # Local directory options
    local_group = parser.add_argument_group("Local directory options")
    local_group.add_argument(
        "--local-dest",
        dest="local_dest",
        help="Root destination directory  [env: DEPUSH_DEPLOY_LOCAL_DEST]",
    )

    # S3 / MinIO options
    s3_group = parser.add_argument_group("S3 / MinIO options")
    s3_group.add_argument(
        "--s3-bucket", dest="s3_bucket", help="[env: DEPUSH_S3_BUCKET]"
    )
    s3_group.add_argument(
        "--s3-endpoint",
        dest="s3_endpoint",
        help="Custom endpoint URL for MinIO  [env: DEPUSH_S3_ENDPOINT]",
    )
    s3_group.add_argument(
        "--s3-region", dest="s3_region", help="[env: DEPUSH_S3_REGION]"
    )
    s3_group.add_argument(
        "--s3-profile",
        dest="s3_profile",
        help="AWS credentials profile name  [env: DEPUSH_S3_PROFILE, fallback: AWS_PROFILE]",
    )
    s3_group.add_argument(
        "--s3-access-key", dest="s3_access_key", help="[env: DEPUSH_S3_ACCESS_KEY]"
    )
    s3_group.add_argument(
        "--s3-secret-key", dest="s3_secret_key", help="[env: DEPUSH_S3_SECRET_KEY]"
    )

    # SSH options
    ssh_group = parser.add_argument_group("SSH options")
    ssh_group.add_argument("--ssh-host", dest="ssh_host", help="[env: DEPUSH_SSH_HOST]")
    ssh_group.add_argument(
        "--ssh-port", dest="ssh_port", type=int, help="[env: DEPUSH_SSH_PORT]"
    )
    ssh_group.add_argument("--ssh-user", dest="ssh_user", help="[env: DEPUSH_SSH_USER]")
    ssh_group.add_argument(
        "--ssh-password", dest="ssh_password", help="[env: DEPUSH_SSH_PASSWORD]"
    )
    ssh_group.add_argument(
        "--ssh-key-file",
        dest="ssh_key_file",
        help="Path to private key  [env: DEPUSH_SSH_KEY_FILE]",
    )
    ssh_group.add_argument(
        "--ssh-deploy-root",
        dest="ssh_deploy_root",
        help="[env: DEPUSH_SSH_DEPLOY_ROOT]",
    )

    return parser


def validate(args):
    if not args.target:
        sys.exit(
            "Error: --target (or DEPUSH_DEPLOY_TARGET) is required. Choose from: s3, ssh, local"
        )
    if not args.prefix:
        sys.exit("Error: --prefix (or DEPUSH_DEPLOY_PREFIX) is required")
    if args.target == "s3" and not args.s3_bucket:
        sys.exit("Error: --s3-bucket (or DEPUSH_S3_BUCKET) is required for s3 target")
    if args.target == "ssh" and not args.ssh_host:
        sys.exit("Error: --ssh-host (or DEPUSH_SSH_HOST) is required for ssh target")
    if args.target == "local" and not args.local_dest:
        sys.exit(
            "Error: --local-dest (or DEPUSH_DEPLOY_LOCAL_DEST) is required for local target"
        )


def main():
    parser = build_parser()

    # Pre-parse to get --config / DEPUSH_DEPLOY_CONFIG before setting defaults
    pre, _ = parser.parse_known_args()
    config_path = pre.config or (
        "depush.yaml" if Path("depush.yaml").exists() else None
    )
    yaml_cfg = load_yaml_config(config_path) if config_path else {}

    # Merge: env vars > YAML > hardcoded defaults, then inject as argparse defaults
    merged_defaults = resolve_defaults(yaml_cfg)
    parser.set_defaults(**merged_defaults)

    args = parser.parse_args()
    validate(args)

    codebase_dir = Path(args.codebase_dir).resolve()
    if not codebase_dir.is_dir():
        sys.exit(f"Error: codebase directory not found: {codebase_dir}")

    if args.version_override:
        version = args.version_override
    else:
        version = read_version(codebase_dir)
    deploy_path = f"{args.prefix}/{version}"

    print(f"Version  : {version}")
    print(f"Path     : {deploy_path}/")
    print(f"Target   : {args.target}")
    effective_config = args.config or (
        "depush.yaml" if Path("depush.yaml").exists() else None
    )
    if effective_config:
        print(f"Config   : {effective_config}")
    if args.dry_run:
        print("Mode     : dry-run")
    print()

    if args.target == "s3":
        deploy_s3(args, codebase_dir, deploy_path)
    elif args.target == "ssh":
        deploy_ssh(args, codebase_dir, deploy_path)
    else:
        deploy_local(args, codebase_dir, deploy_path)


if __name__ == "__main__":
    main()
