#!/usr/bin/env python3
"""Provision trusted HTTPS for Daneshyar over SSH.

The script is safe to rerun. It keeps Apache online, uses an ACME webroot
challenge, backs up the existing Daneshyar virtual host, installs the isolated
Certbot snap, configures renewal, updates Laravel's public URL, and verifies the
result from the machine running this script.

Default usage:

    python scripts/enable_https.py

Authentication is read from DANESHYAR_SSH_PASSWORD or prompted without echo.
No SSH password is stored in this file.
"""

from __future__ import annotations

import argparse
import getpass
import os
import re
import shlex
import socket
import ssl
import sys
import urllib.error
import urllib.request
from pathlib import Path


DEFAULT_HOST = "91.107.131.171"
DEFAULT_PORT = 2435
DEFAULT_USER = "root"
DEFAULT_DOMAIN = "mydaneshyar.ir"
DEFAULT_EMAIL = "shayanhamidi05@gmail.com"
DEFAULT_APP_DIR = "/var/www/html"

DOMAIN_RE = re.compile(
    r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$"
)
EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")


REMOTE_PROVISIONER = r'''#!/usr/bin/env bash
set -Eeuo pipefail

DOMAIN="$1"
EMAIL="$2"
APP_DIR="$3"
INCLUDE_WWW="$4"
RUN_DRY_RUN="$5"

VHOST="/etc/apache2/sites-available/daneshyar.conf"
WEBROOT="/var/www/letsencrypt"
CERTBOT="/snap/bin/certbot"
TIMESTAMP="$(date -u +%Y%m%d-%H%M%S)"
BACKUP="${VHOST}.before-https-${TIMESTAMP}"
DOMAIN_REGEX="${DOMAIN//./\\.}"
VHOST_COMMITTED="no"

log() { printf '\n[%s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }

restore_vhost() {
    if [ -f "$BACKUP" ]; then
        cp -a "$BACKUP" "$VHOST"
        apache2ctl configtest >/dev/null 2>&1 && systemctl reload apache2 || true
    fi
}

trap 'status=$?; if [ "$status" -ne 0 ]; then printf "\nProvisioning failed (exit %s).\n" "$status" >&2; if [ "$VHOST_COMMITTED" != "yes" ]; then restore_vhost; fi; fi; exit "$status"' EXIT

[ "$(id -u)" -eq 0 ] || die "Run the remote provisioner as root."
[[ "$DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || die "Invalid domain."
[[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || die "Invalid email."
[ -d "$APP_DIR/public" ] || die "Laravel public directory not found: $APP_DIR/public"

log "Inspecting port ownership"
for port in 80 443; do
    listeners="$(ss -ltnp 2>/dev/null | awk -v p=":${port}" '$4 ~ p"$" {print}' || true)"
    if [ -n "$listeners" ] && ! grep -q 'apache2' <<<"$listeners"; then
        printf '%s\n' "$listeners" >&2
        die "Port ${port} is owned by a non-Apache service. Refusing to stop an unrelated site automatically."
    fi
done

log "Installing Apache, snapd, and TLS prerequisites"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq apache2 snapd ca-certificates curl openssl
systemctl enable --now apache2
a2enmod rewrite ssl headers expires deflate proxy proxy_http proxy_wstunnel >/dev/null

if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then
    ufw allow 80/tcp >/dev/null
    ufw allow 443/tcp >/dev/null
fi

log "Repairing Certbot with the isolated official snap"
systemctl disable --now certbot.timer >/dev/null 2>&1 || true
apt-get remove -y -qq certbot python3-certbot python3-certbot-apache >/dev/null 2>&1 || true
systemctl enable --now snapd.socket
timeout 180 snap wait system seed.loaded >/dev/null 2>&1 || true
if ! snap list core >/dev/null 2>&1; then
    snap install core
else
    snap refresh core >/dev/null 2>&1 || true
fi
if ! snap list certbot >/dev/null 2>&1; then
    snap install --classic certbot
else
    snap refresh certbot >/dev/null 2>&1 || true
fi
ln -sfn /snap/bin/certbot /usr/local/bin/certbot
"$CERTBOT" --version

install -d -m 0755 "$WEBROOT/.well-known/acme-challenge"
if [ -f "$VHOST" ]; then
    cp -a "$VHOST" "$BACKUP"
    printf 'Vhost backup: %s\n' "$BACKUP"
fi

server_alias=""
if [ "$INCLUDE_WWW" = "yes" ]; then
    server_alias="ServerAlias www.${DOMAIN}"
fi

write_bootstrap_vhost() {
    cat >"$VHOST" <<EOF
# Daneshyar Apache virtual host
# Generated by scripts/enable_https.py

<VirtualHost *:80>
    ServerName ${DOMAIN}
    ${server_alias}
    DocumentRoot ${APP_DIR}/public

    Alias /.well-known/acme-challenge/ ${WEBROOT}/.well-known/acme-challenge/
    <Directory ${WEBROOT}/.well-known/acme-challenge/>
        AllowOverride None
        Options None
        Require all granted
    </Directory>

    <Directory ${APP_DIR}/public>
        AllowOverride All
        Options -Indexes -MultiViews
        Require all granted
        DirectoryIndex index.php index.html
    </Directory>

    <FilesMatch "(^\\.|\\.(?:bak|config|dist|ini|log|sh|sql|swp)$|^(?:artisan|composer\\.(?:json|lock)|package(?:-lock)?\\.json|vite\\.config\\.ts)$)">
        Require all denied
    </FilesMatch>

    ErrorLog \${APACHE_LOG_DIR}/daneshyar_error.log
    CustomLog \${APACHE_LOG_DIR}/daneshyar_access.log combined
</VirtualHost>
EOF
}

write_final_vhost() {
    cat >"$VHOST" <<EOF
# Daneshyar Apache HTTPS virtual host
# Generated by scripts/enable_https.py

<VirtualHost *:80>
    ServerName ${DOMAIN}
    ${server_alias}

    Alias /.well-known/acme-challenge/ ${WEBROOT}/.well-known/acme-challenge/
    <Directory ${WEBROOT}/.well-known/acme-challenge/>
        AllowOverride None
        Options None
        Require all granted
    </Directory>

    RewriteEngine On
    RewriteCond %{REQUEST_URI} !^/\\.well-known/acme-challenge/
    RewriteRule ^ https://${DOMAIN}%{REQUEST_URI} [R=301,L,NE]

    ErrorLog \${APACHE_LOG_DIR}/daneshyar_error.log
    CustomLog \${APACHE_LOG_DIR}/daneshyar_access.log combined
</VirtualHost>

<VirtualHost *:443>
    ServerName ${DOMAIN}
    ${server_alias}
    DocumentRoot ${APP_DIR}/public

    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/${DOMAIN}/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/${DOMAIN}/privkey.pem
    SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
    SSLCompression off

    <Directory ${APP_DIR}/public>
        AllowOverride All
        Options -Indexes -MultiViews
        Require all granted
        DirectoryIndex index.php index.html
    </Directory>

    <FilesMatch "(^\\.|\\.(?:bak|config|dist|ini|log|sh|sql|swp)$|^(?:artisan|composer\\.(?:json|lock)|package(?:-lock)?\\.json|vite\\.config\\.ts)$)">
        Require all denied
    </FilesMatch>

    # Laravel also emits HSTS for dynamic responses. Remove its normal-table
    # copy before setting one canonical header that also covers static files.
    Header onsuccess unset X-Content-Type-Options
    Header onsuccess unset X-Frame-Options
    Header onsuccess unset X-XSS-Protection
    Header onsuccess unset Referrer-Policy
    Header onsuccess unset Permissions-Policy
    Header onsuccess unset Strict-Transport-Security
    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

    # Reverb stays private on localhost; Apache terminates TLS and exposes the
    # standard same-origin /app and /apps endpoints over wss/https.
    RewriteEngine On
    RewriteCond %{REQUEST_URI} ^/app(?:/|$)
    RewriteCond %{HTTP:Origin} !^$
    RewriteCond %{HTTP:Origin} !^https://(www\.)?${DOMAIN_REGEX}$ [NC]
    RewriteRule ^ - [F,L]
    ProxyPreserveHost On
    ProxyTimeout 3600
    ProxyPass /apps http://127.0.0.1:8081/apps retry=0 timeout=60
    ProxyPassReverse /apps http://127.0.0.1:8081/apps
    ProxyPass /app ws://127.0.0.1:8081/app retry=0 timeout=3600
    ProxyPassReverse /app ws://127.0.0.1:8081/app

    RewriteEngine On
    RewriteCond %{HTTP_HOST} !^${DOMAIN_REGEX}$ [NC]
    RewriteRule ^ https://${DOMAIN}%{REQUEST_URI} [R=301,L,NE]

    ErrorLog \${APACHE_LOG_DIR}/daneshyar_ssl_error.log
    CustomLog \${APACHE_LOG_DIR}/daneshyar_ssl_access.log combined
</VirtualHost>
EOF
}

log "Installing the domain-aware HTTP virtual host"
if [ -s "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ] && [ -s "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" ]; then
    write_final_vhost
else
    write_bootstrap_vhost
fi
a2ensite daneshyar.conf >/dev/null
apache2ctl configtest
systemctl reload apache2

challenge="daneshyar-${TIMESTAMP}-$$"
printf '%s' "$challenge" >"$WEBROOT/.well-known/acme-challenge/$challenge"
served="$(curl -fsS --max-time 20 "http://${DOMAIN}/.well-known/acme-challenge/${challenge}" || true)"
rm -f "$WEBROOT/.well-known/acme-challenge/$challenge"
[ "$served" = "$challenge" ] || die "The public HTTP ACME challenge path is not reaching this Apache server."

log "Requesting or reusing the trusted Let's Encrypt certificate"
cert_args=(certonly --webroot --webroot-path "$WEBROOT" --cert-name "$DOMAIN" --non-interactive --agree-tos --no-eff-email --email "$EMAIL" --keep-until-expiring -d "$DOMAIN")
if [ "$INCLUDE_WWW" = "yes" ]; then
    cert_args+=( -d "www.${DOMAIN}" )
fi
"$CERTBOT" "${cert_args[@]}"
[ -s "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ] || die "Certificate full chain was not created."
[ -s "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" ] || die "Certificate private key was not created."

log "Enabling the final HTTPS virtual host"
write_final_vhost
apache2ctl configtest
systemctl reload apache2
VHOST_COMMITTED="yes"

log "Configuring automatic renewal and Apache reload"
install -d -m 0755 /etc/letsencrypt/renewal-hooks/deploy
cat >/etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh <<'EOF'
#!/usr/bin/env bash
set -eu
apache2ctl configtest
systemctl reload apache2
EOF
chmod 0755 /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh

if systemctl list-unit-files snap.certbot.renew.timer --no-legend 2>/dev/null | grep -q 'snap.certbot.renew.timer'; then
    systemctl enable --now snap.certbot.renew.timer >/dev/null
else
    cat >/etc/systemd/system/daneshyar-certbot-renew.service <<EOF
[Unit]
Description=Renew Let's Encrypt certificates for Daneshyar
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=${CERTBOT} renew --quiet
EOF
    cat >/etc/systemd/system/daneshyar-certbot-renew.timer <<'EOF'
[Unit]
Description=Twice-daily Certbot renewal check

[Timer]
OnCalendar=*-*-* 03,15:17:00
RandomizedDelaySec=3600
Persistent=true

[Install]
WantedBy=timers.target
EOF
    systemctl daemon-reload
    systemctl enable --now daneshyar-certbot-renew.timer >/dev/null
fi

if [ "$RUN_DRY_RUN" = "yes" ]; then
    log "Testing certificate renewal against the Let's Encrypt staging service"
    "$CERTBOT" renew --cert-name "$DOMAIN" --dry-run
fi

log "Updating Laravel HTTPS settings"
ENV_FILE=""
if [ -f /var/www/daneshyar-shared/.env ]; then
    ENV_FILE=/var/www/daneshyar-shared/.env
elif [ -f "$APP_DIR/.env" ]; then
    ENV_FILE="$APP_DIR/.env"
fi

if [ -n "$ENV_FILE" ]; then
    cp -a "$ENV_FILE" "${ENV_FILE}.before-https-${TIMESTAMP}"
    python3 - "$ENV_FILE" "$DOMAIN" <<'PY'
from pathlib import Path
import sys

path = Path(sys.argv[1])
domain = sys.argv[2]
desired = {
    "APP_URL": f"https://{domain}",
    "ASSET_URL": f"https://{domain}",
    "SESSION_SECURE_COOKIE": "true",
    "SESSION_DOMAIN": domain,
}
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
seen = set()
result = []
for line in lines:
    if not line or line.lstrip().startswith("#") or "=" not in line:
        result.append(line)
        continue
    key, _ = line.split("=", 1)
    if key in desired:
        result.append(f"{key}={desired[key]}")
        seen.add(key)
    else:
        result.append(line)
for key, value in desired.items():
    if key not in seen:
        result.append(f"{key}={value}")
path.write_text("\n".join(result) + "\n", encoding="utf-8")
PY
    if [ -f "$APP_DIR/artisan" ]; then
        cd "$APP_DIR"
        runuser -u www-data -- php artisan optimize:clear
        runuser -u www-data -- php artisan config:cache
    fi
else
    printf 'WARNING: Laravel .env was not found; HTTPS environment values were not updated.\n' >&2
fi

log "Final server-side checks"
apache2ctl configtest
curl -fsSIL --max-time 30 "http://${DOMAIN}/" | sed -n '1,12p'
curl -fsSIL --max-time 30 "https://${DOMAIN}/" | sed -n '1,20p'
openssl x509 -in "/etc/letsencrypt/live/${DOMAIN}/cert.pem" -noout -subject -issuer -dates -ext subjectAltName
systemctl list-timers --all | grep -E 'certbot|NEXT|LEFT' || true

trap - EXIT
printf '\nHTTPS_PROVISIONING_COMPLETE domain=%s backup=%s\n' "$DOMAIN" "$BACKUP"
'''


class ProvisionError(RuntimeError):
    """Raised when validation, SSH, or remote provisioning fails."""


def quote(value: str) -> str:
    return shlex.quote(value)


def validate_args(args: argparse.Namespace) -> None:
    args.domain = args.domain.lower().rstrip(".")
    if not DOMAIN_RE.fullmatch(args.domain):
        raise ProvisionError(f"Invalid domain: {args.domain}")
    if not EMAIL_RE.fullmatch(args.email):
        raise ProvisionError(f"Invalid email: {args.email}")
    if not args.app_dir.startswith("/") or "\x00" in args.app_dir:
        raise ProvisionError("--app-dir must be an absolute server path")
    if not 1 <= args.port <= 65535:
        raise ProvisionError("--port must be between 1 and 65535")


def ipv4_addresses(name: str) -> set[str]:
    try:
        return {
            item[4][0]
            for item in socket.getaddrinfo(name, 80, socket.AF_INET, socket.SOCK_STREAM)
        }
    except socket.gaierror:
        return set()


def check_dns(args: argparse.Namespace) -> None:
    if args.skip_dns_check:
        print("DNS preflight skipped by request.")
        return

    server_ips = ipv4_addresses(args.host)
    if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}", args.host):
        server_ips.add(args.host)
    if not server_ips:
        raise ProvisionError(f"Could not resolve SSH host {args.host}")

    names = [args.domain]
    if not args.no_www:
        names.append(f"www.{args.domain}")

    print("DNS preflight:")
    for name in names:
        resolved = ipv4_addresses(name)
        print(f"  {name}: {', '.join(sorted(resolved)) or 'NO A RECORD'}")
        if not resolved:
            raise ProvisionError(f"{name} has no public IPv4 A record")
        if resolved.isdisjoint(server_ips):
            raise ProvisionError(
                f"{name} resolves to {sorted(resolved)}, not SSH server {sorted(server_ips)}"
            )


def require_paramiko():
    try:
        import paramiko
    except ImportError as exc:
        raise ProvisionError(
            "Paramiko is required. Run: python -m pip install paramiko"
        ) from exc
    return paramiko


def connect(args: argparse.Namespace, password: str):
    paramiko = require_paramiko()
    client = paramiko.SSHClient()
    client.load_system_host_keys()
    known_hosts = Path.home() / ".ssh" / "known_hosts"
    if known_hosts.exists():
        client.load_host_keys(str(known_hosts))
    if args.accept_new_host_key:
        client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    else:
        client.set_missing_host_key_policy(paramiko.RejectPolicy())
    print(f"Connecting to {args.user}@{args.host}:{args.port} ...")
    try:
        client.connect(
            args.host,
            port=args.port,
            username=args.user,
            password=password,
            timeout=30,
            banner_timeout=30,
            auth_timeout=30,
            look_for_keys=True,
            allow_agent=True,
        )
    except Exception as exc:
        raise ProvisionError(f"SSH connection failed: {exc}") from exc
    transport = client.get_transport()
    if transport is not None:
        transport.set_keepalive(20)
    if args.accept_new_host_key:
        known_hosts.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
        client.save_host_keys(str(known_hosts))
    print("Connected.\n")
    return client


def run_remote(client, command: str) -> None:
    stdin, stdout, stderr = client.exec_command(command, get_pty=True)
    stdin.close()
    for line in iter(stdout.readline, ""):
        print(line, end="")
    status = stdout.channel.recv_exit_status()
    error = stderr.read().decode("utf-8", errors="replace").strip()
    if error:
        print(error, file=sys.stderr)
    if status != 0:
        raise ProvisionError(f"Remote provisioner failed with exit code {status}")


def upload_and_run(client, args: argparse.Namespace) -> None:
    remote_path = f"/tmp/daneshyar-enable-https-{os.getpid()}.sh"
    sftp = client.open_sftp()
    try:
        with sftp.file(remote_path, "w") as remote:
            remote.write(REMOTE_PROVISIONER)
        sftp.chmod(remote_path, 0o700)
    finally:
        sftp.close()

    command = " ".join(
        [
            quote(remote_path),
            quote(args.domain),
            quote(args.email),
            quote(args.app_dir),
            "no" if args.no_www else "yes",
            "no" if args.skip_renewal_dry_run else "yes",
        ]
    )
    try:
        run_remote(client, command)
    finally:
        client.exec_command(f"rm -f {quote(remote_path)}")


def verify_https(args: argparse.Namespace) -> None:
    print("\nExternal verification:")
    context = ssl.create_default_context()
    with socket.create_connection((args.domain, 443), timeout=20) as raw:
        with context.wrap_socket(raw, server_hostname=args.domain) as tls:
            certificate = tls.getpeercert()
            cipher = tls.cipher()
            print(f"  TLS version: {tls.version()}")
            print(f"  Cipher: {cipher[0] if cipher else 'unknown'}")
            print(f"  Certificate subject: {certificate.get('subject')}")
            print(f"  Certificate expires: {certificate.get('notAfter')}")

    request = urllib.request.Request(
        f"https://{args.domain}/", headers={"User-Agent": "Daneshyar-HTTPS-Check/1.0"}
    )
    try:
        with urllib.request.urlopen(request, timeout=30) as response:
            print(f"  HTTPS status: {response.status}")
            print(f"  Final URL: {response.geturl()}")
            hsts = response.headers.get("Strict-Transport-Security", "MISSING")
            print(f"  HSTS: {hsts}")
    except urllib.error.HTTPError as exc:
        raise ProvisionError(f"HTTPS returned HTTP {exc.code}") from exc


def parser() -> argparse.ArgumentParser:
    result = argparse.ArgumentParser(
        description="Install and verify trusted HTTPS for the Daneshyar Apache site."
    )
    result.add_argument("--host", default=DEFAULT_HOST, help="SSH host/IP")
    result.add_argument("--port", type=int, default=DEFAULT_PORT, help="SSH port")
    result.add_argument("--user", default=DEFAULT_USER, help="SSH user (root expected)")
    result.add_argument("--domain", default=DEFAULT_DOMAIN, help="Apex domain")
    result.add_argument("--email", default=DEFAULT_EMAIL, help="Let's Encrypt email")
    result.add_argument("--app-dir", default=DEFAULT_APP_DIR, help="Remote Laravel directory")
    result.add_argument("--no-www", action="store_true", help="Do not include www.<domain>")
    result.add_argument(
        "--skip-dns-check",
        action="store_true",
        help="Skip local A-record validation (useful only behind a proxy/CDN)",
    )
    result.add_argument(
        "--skip-renewal-dry-run",
        action="store_true",
        help="Skip the Let's Encrypt staging renewal test",
    )
    result.add_argument(
        "--accept-new-host-key",
        action="store_true",
        help="Trust an unknown SSH host key (existing known-host entries remain verified)",
    )
    return result


def main() -> int:
    args = parser().parse_args()
    try:
        validate_args(args)
        check_dns(args)
        password = os.environ.get("DANESHYAR_SSH_PASSWORD")
        if password is None:
            password = getpass.getpass(
                f"SSH password for {args.user}@{args.host}:{args.port} "
                "(leave blank to use SSH key/agent): "
            )
        client = connect(args, password)
        try:
            upload_and_run(client, args)
        finally:
            client.close()
        verify_https(args)
    except (ProvisionError, OSError, ssl.SSLError) as exc:
        print(f"\nERROR: {exc}", file=sys.stderr)
        return 1

    print(f"\nHTTPS is fully configured: https://{args.domain}/")
    return 0


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