#!/usr/bin/env python3
"""Standard-library Mushroom Forecast LAN client. Credentials come only from the environment."""

import argparse
import json
import os
import shutil
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request


class ApiError(Exception):
    def __init__(self, status, raw):
        self.status = status
        self.raw = raw
        super().__init__(f"HTTP {status}\n{raw}")


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def validate_url(base_url):
    try:
        url = urllib.parse.urlsplit(base_url)
        port = url.port
        valid = (
            url.scheme == "http" and url.hostname
            and url.path in ("", "/") and not url.query and not url.fragment
            and url.username is None and url.password is None
            and "?" not in base_url and "#" not in base_url
            and not any(c.isspace() for c in base_url)
            and (port is None or port > 0)
        )
    except ValueError:
        valid = False
    if not valid:
        raise ValueError("GUIJI_APP_URL must be an HTTP APP address without credentials, path, query or fragment")


def request(base_url, token, path, payload=None):
    validate_url(base_url)
    if not token or any(c in token for c in "\r\n"):
        raise ValueError("Set a valid GUIJI_APP_TOKEN from APP settings")
    if path not in ("/api/v1/state", "/api/v1/command"):
        raise ValueError("Unknown API endpoint")
    data = None if payload is None else json.dumps(payload, allow_nan=False).encode("utf-8")
    req = urllib.request.Request(
        base_url.rstrip("/") + path,
        data=data,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        method="GET" if data is None else "POST",
    )
    # Never forward the Bearer token through environment proxies or redirects.
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect())
    try:
        with opener.open(req, timeout=30) as response:
            raw = response.read().decode("utf-8")
    except urllib.error.HTTPError as exc:
        with exc:
            raw = exc.read().decode("utf-8", errors="replace")
        raise ApiError(exc.code, raw) from exc
    except (urllib.error.URLError, TimeoutError) as exc:
        raise RuntimeError("Cannot reach APP; check LAN, address, switch and foreground. Request may already have executed; do not blindly retry.") from exc
    except UnicodeError as exc:
        raise RuntimeError("APP returned invalid UTF-8") from exc
    try:
        body = json.loads(raw)
    except ValueError as exc:
        raise RuntimeError("APP returned invalid JSON") from exc
    if isinstance(body, dict) and body.get("ok") is False:
        raise ApiError(200, raw)
    return body


def command(base_url, token, action, params):
    if not isinstance(params, dict):
        raise ValueError("params must be a JSON object")
    return request(base_url, token, "/api/v1/command", {"action": action, "params": params})


def discover(name=None):
    """Show native DNS-SD output once; do not pick a phone or obtain its token."""
    if shutil.which("dns-sd"):
        cmd = ["dns-sd", "-L", name, "_guiji-agent._tcp"] if name else ["dns-sd", "-B", "_guiji-agent._tcp"]
    elif shutil.which("avahi-browse"):
        cmd = ["avahi-browse", "-rt", "_guiji-agent._tcp"]
    else:
        raise RuntimeError("No dns-sd or avahi-browse found; ask the user for the address in APP settings and set GUIJI_APP_URL")
    # Native browsers can run indefinitely. Bound this one invocation; no retries.
    try:
        result = subprocess.run(cmd, capture_output=True, timeout=5)
        stdout, stderr = result.stdout, result.stderr
        failed = result.returncode != 0
    except subprocess.TimeoutExpired as exc:
        stdout, stderr = exc.stdout or b"", exc.stderr or b""
        failed = False
    if stdout:
        print(stdout.decode("utf-8", errors="replace"), end="")
    if stderr:
        print(stderr.decode("utf-8", errors="replace"), end="", file=sys.stderr)
    if failed:
        raise RuntimeError("Discovery tool failed; ask the user for the address in APP settings")
    print('Confirm the phone with the user. With dns-sd, resolve the discovered name using discover --name "<service name>". Set GUIJI_APP_URL=http://<resolved host or IP>:<port>. Discovery never supplies a token.', file=sys.stderr)
    if not stdout:
        print("No discovery output; ask for the address in APP settings.", file=sys.stderr)


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest="operation", required=True)
    sub.add_parser("state", help="GET /api/v1/state")
    cmd = sub.add_parser("command", help="POST any supported action")
    cmd.add_argument("action")
    cmd.add_argument("--params", default="{}", help="JSON object (default: {})")
    browse = sub.add_parser("discover", help="Bounded Bonjour discovery, without credentials")
    browse.add_argument("--name", help="Exact service name to resolve with dns-sd")
    args = parser.parse_args(argv)
    try:
        if args.operation == "discover":
            discover(args.name)
            return 0
        url = os.environ.get("GUIJI_APP_URL", "")
        token = os.environ.get("GUIJI_APP_TOKEN", "")
        if not url:
            raise ValueError("Set GUIJI_APP_URL from APP settings or run discover")
        if args.operation == "state":
            result = request(url, token, "/api/v1/state")
        else:
            try:
                params = json.loads(args.params)
            except ValueError:
                raise ValueError("--params must be a JSON object") from None
            result = command(url, token, args.action, params)
        print(json.dumps(result, ensure_ascii=False, indent=2))
    except (ApiError, RuntimeError, ValueError, OSError) as exc:
        print(str(exc), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
