#!/usr/bin/env python3
"""dns-sync.py — reconcile the fleet's AdGuard resolvers against dns/internal.yaml.

Source of truth is the file; the resolvers are derived state. Same posture as
deploy-stack.sh: show a diff, ask, then apply.

  scripts/dns-sync.py                 # diff every resolver, prompt before applying
  scripts/dns-sync.py --dry-run       # diff only, never write
  scripts/dns-sync.py --yes           # skip the prompt
  scripts/dns-sync.py --site esh      # one resolver

AUTHORITY IS SCOPED TO THE ZONE, NOT THE RESOLVER. Only rewrites ending in
`.internal` are considered. The ESH resolver carries hand-made `esteban.net`
entries that predate this system; they are read, ignored, and left alone. If
this ever grows to manage other zones, that scoping is the thing to be careful
with — a resolver-wide authority would silently delete a colleague's work.

CREDENTIAL: pulled from the vault, never hardcoded.
    secret get nh3-dev/adguard-infra-ops-password
The vault appends a trailing newline on read; it is stripped here, because a
password with a stray \\n fails auth in a way that looks like a wrong password.
"""

from __future__ import annotations

import argparse
import base64
import json
import pathlib
import subprocess
import sys
import urllib.error
import urllib.request

REPO = pathlib.Path(__file__).resolve().parent.parent
SPEC = REPO / "dns" / "internal.yaml"
SECRET_CLI = REPO / "services" / "secrets-broker" / "secret"
SECRET_NAME = "nh3-dev/adguard-infra-ops-password"
DEFAULT_API_PORT = 8080
USER = "infra-ops"
TIMEOUT = 10


def load_spec() -> dict:
    import yaml  # local import so --help works without the dep
    return yaml.safe_load(SPEC.read_text())


def get_password() -> str:
    try:
        out = subprocess.run([str(SECRET_CLI), "get", SECRET_NAME],
                             capture_output=True, text=True, timeout=60)
    except FileNotFoundError:
        sys.exit(f"secret CLI not found at {SECRET_CLI}")
    if out.returncode != 0:
        sys.exit(f"could not read {SECRET_NAME} from the vault:\n{out.stderr.strip()}")
    pw = out.stdout.strip("\n")
    if not pw:
        sys.exit(f"{SECRET_NAME} came back empty")
    return pw


def desired_pairs(spec: dict) -> set[tuple[str, str]]:
    """The (domain, answer) pairs the zone should contain.

    A pair IS AdGuard's identity for a rewrite, which is why this is a set of
    tuples rather than a name->address map: a dual-stack host is two rewrites
    that share one name, and a map would silently drop one of them.

    Every name is published to every resolver — the site label says where a
    host IS, not which resolver knows about it.
    """
    zone = spec["zone"]
    hosts = spec.get("hosts") or []
    by_name = {h["name"]: h for h in hosts}
    pairs: set[tuple[str, str]] = set()

    def emit(fqdn: str, host: dict) -> None:
        for key in ("v4", "v6"):
            if host.get(key):
                pairs.add((fqdn, str(host[key])))

    seen: set[str] = set()
    for h in hosts:
        fqdn = f"{h['name']}.{h['site']}.{zone}"
        if fqdn in seen:
            sys.exit(f"duplicate name in dns/internal.yaml: {fqdn}")
        seen.add(fqdn)
        emit(fqdn, h)

    for a in spec.get("aliases") or []:
        target = by_name.get(a["target"])
        if target is None:
            sys.exit(f"alias {a['name']} points at unknown host {a['target']!r}")
        fqdn = f"{a['name']}.{a['site']}.{zone}"
        if fqdn in seen:
            sys.exit(f"alias {fqdn} collides with a host of the same name")
        seen.add(fqdn)
        emit(fqdn, target)

    return pairs


def api(host: str, path: str, pw: str, payload: dict | None = None,
        port: int = DEFAULT_API_PORT):
    url = f"http://{host}:{port}/control/{path}"
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
    token = base64.b64encode(f"{USER}:{pw}".encode()).decode()
    req.add_header("Authorization", f"Basic {token}")
    if data:
        req.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            body = r.read().decode().strip()
            return json.loads(body) if body else None
    except urllib.error.HTTPError as e:
        sys.exit(f"{host}: {path} -> HTTP {e.code} {e.reason}\n{e.read().decode()[:200]}")
    except urllib.error.URLError as e:
        sys.exit(f"{host}: unreachable ({e.reason}). Tried port {port}; "
                 f"run this from a host that can reach it.")


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--site", help="only this site's resolver")
    ap.add_argument("--dry-run", action="store_true", help="diff only, never write")
    ap.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
    args = ap.parse_args()

    spec = load_spec()
    zone_suffix = "." + spec["zone"]
    want = desired_pairs(spec)

    sites = spec["sites"]
    if args.site:
        if args.site not in sites:
            sys.exit(f"unknown site {args.site!r}; known: {', '.join(sites)}")
        sites = {args.site: sites[args.site]}

    pw = get_password()
    plans = {}

    for site, cfg in sites.items():
        host = cfg["resolver"]
        port = int(cfg.get("api_port", DEFAULT_API_PORT))
        current_all = api(host, "rewrite/list", pw, port=port) or []
        # SCOPE: only our zone. Everything else on this resolver is somebody
        # else's and stays untouched.
        current = {(r["domain"], r["answer"]) for r in current_all
                   if r["domain"].endswith(zone_suffix)}
        foreign = len(current_all) - len(current)

        add = sorted(want - current)
        remove = sorted(current - want)
        plans[site] = (host, port, add, remove, foreign)

        print(f"\n=== {site} ({host}:{port}) ===")
        print(f"  in zone: {len(current)}   outside zone (left alone): {foreign}")
        for d, a in add:
            print(f"  + {d:<44} {a}")
        for d, a in remove:
            print(f"  - {d:<44} {a}")
        if not add and not remove:
            print("  in sync")

    total = sum(len(a) + len(r) for _, _, a, r, _ in plans.values())
    if total == 0:
        print("\nnothing to do.")
        return
    if args.dry_run:
        print(f"\n--dry-run: {total} change(s) NOT applied.")
        return
    if not args.yes:
        if input(f"\napply {total} change(s)? [y/N] ").strip().lower() not in ("y", "yes"):
            sys.exit("aborted.")

    for site, (host, port, add, remove, _) in plans.items():
        # Delete first: AdGuard tolerates duplicate (domain, answer) pairs, so
        # removing before adding keeps a re-pointed name from briefly resolving
        # to BOTH its old and new address.
        for d, a in remove:
            api(host, "rewrite/delete", pw, {"domain": d, "answer": a}, port=port)
        for d, a in add:
            api(host, "rewrite/add", pw, {"domain": d, "answer": a}, port=port)
        print(f"{site}: -{len(remove)} +{len(add)}")

    print("done.")


if __name__ == "__main__":
    main()
