"""Update managed nginx transport without discarding Certbot's HTTPS configuration."""
import hashlib
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
import tempfile


class NginxRejected(Exception):
    """nginx refused the updated vhost; the previous file is back in place."""


def transport(text, domain):
    variable = "$gowalk_backend_connection_" + hashlib.sha256(domain.encode()).hexdigest()[:16]
    mapping = f"map $http_upgrade {variable} {{\n    default upgrade;\n    '' close;\n}}\n\n"
    result = text[len(mapping):] if text.startswith(mapping) else text
    directives = (r"proxy_http_version", r"proxy_set_header[ \t]+Upgrade",
                  r"proxy_set_header[ \t]+Connection", r"proxy_buffering")
    for directive in directives:
        pattern = rf"(?mi)^[ \t]*{directive}[ \t]+[^;\n]+;[^\n]*\n"
        result, count = re.subn(pattern, "", result)
        if count > 1:
            raise ValueError("managed transport directive is not unique")
    if variable in result:
        raise ValueError("managed connection map is not recognized")
    settings = ("proxy_http_version 1.1;", "proxy_set_header Upgrade $http_upgrade;",
                f"proxy_set_header Connection {variable};", "proxy_buffering off;")
    pattern = r"(?m)^([ \t]*)proxy_pass[ \t]+http://127\.0\.0\.1:\d+;[^\n]*$"
    result, count = re.subn(pattern, lambda match: match[0] + "\n"
                            + "\n".join(match[1] + setting for setting in settings), result)
    if count != 1:
        raise ValueError("managed upstream is not unique")
    return mapping + result


def content(previous, domain, port):
    if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", domain) or not 0 < port < 65536:
        raise ValueError("invalid domain or port")
    if previous:
        names = re.findall(r"(?m)^\s*server_name\s+([^;]+);", previous)
        if not names or any(name.strip() != domain for name in names):
            raise ValueError("vhost belongs to another domain")
        pattern = r"(?m)^(\s*proxy_pass\s+http://127\.0\.0\.1:)\d+(;[^\n]*$)"
        result, count = re.subn(pattern, lambda match: f"{match[1]}{port}{match[2]}", previous)
        if count != 1:
            raise ValueError("managed upstream is not unique")
        return transport(result, domain)
    return transport(f"""server {{
    listen 80;
    server_name {domain};
    client_max_body_size 100m;
    location / {{
        proxy_pass http://127.0.0.1:{port};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 120s;
    }}
}}
""", domain)


def replace(path, data, mode):
    fd, temporary = tempfile.mkstemp(prefix=path.name + ".", dir=path.parent)
    try:
        with os.fdopen(fd, "wb") as output:
            os.fchmod(output.fileno(), mode)
            output.write(data)
            output.flush()
            os.fsync(output.fileno())
        os.replace(temporary, path)
    finally:
        Path(temporary).unlink(missing_ok=True)


def configure(path, domain, port, run=subprocess.run):
    """Write the vhost, then test and reload nginx; on any failure restore the previous file, as edge does.

    A change nginx refuses must not stay on disk: every later `nginx -t` on the host, this deploy's own
    certificate step included, would fail on it, and the next deploy would find the file unchanged. The test
    and reload run here, in one process that writes nothing to the deploy's output, so a cancelled deploy's
    dropped SSH session cannot stop between them.
    """
    if path.is_symlink():
        raise ValueError("managed vhost must not be a symlink")
    previous = path.read_text() if path.exists() else ""
    updated = content(previous, domain, port)
    if previous == updated:
        return "unchanged"
    saved = path.read_bytes() if path.exists() else None
    mode = stat.S_IMODE(path.stat().st_mode) if saved is not None else 0o644
    replace(path, updated.encode(), mode)
    try:
        run(["nginx", "-t"], capture_output=True, timeout=30, check=True)
        run(["systemctl", "reload", "nginx"], capture_output=True, timeout=30, check=True)
    except (OSError, subprocess.SubprocessError):
        if saved is None:
            path.unlink(missing_ok=True)
        else:
            replace(path, saved, mode)
        raise NginxRejected from None
    return "changed"


if __name__ == "__main__":
    try:
        print(configure(Path(sys.argv[1]), sys.argv[2], int(sys.argv[3])))
    except NginxRejected:
        sys.exit("nginx rejected the managed vhost update; the previous vhost is restored")
    except (OSError, ValueError):
        sys.exit("Cannot update the managed nginx vhost; its domain and upstream must be unambiguous")
