"""Host problem diagnosis for dashboard chat ("why isn't my internet working?").

Backs the ``run_host_diagnostics`` agent tool with eight domains:

- ``network``   — layered decision tree: link → DHCP → gateway → DNS → captive
  portal → external reachability. Stops at the first failing layer and names it.
- ``app``       — recent crash reports (``~/Library/Logs/DiagnosticReports/*.ips``,
  user-readable JSON) + unprivileged ``/usr/bin/log show`` errors for a process.
- ``printing``  — CUPS state via ``lpstat``: paused queues, stuck jobs, printer
  reachability.
- ``power``     — battery health/charging (``pmset``, ``SPPowerDataType``) and
  which app holds a sleep assertion.
- ``bluetooth`` — controller state + connected / paired-but-disconnected devices.
- ``audio``     — mute/volume and where sound output is actually routed.
- ``displays``  — external monitor not-detected vs mirrored vs healthy.
- ``storage``   — disk capacity, biggest files (Spotlight), known space hogs,
  and local Time Machine snapshots; ``target=path`` drills into one folder.

Everything is read-only and runs without sudo, with per-command timeouts.
Fixes that mutate state (resume a print queue, renew a DHCP lease) are left to
``shell_execute`` so the existing user-confirmation flow gates them.
"""

from __future__ import annotations

import json
import os
import re
import socket
import subprocess
import time
from datetime import datetime, timedelta
from urllib.request import Request, urlopen

_CMD_TIMEOUT = 8.0
_PING_TIMEOUT = 4.0
_HTTP_TIMEOUT = 5.0
CAPTIVE_PROBE_URL = "http://captive.apple.com/hotspot-detect.html"
EXTERNAL_PROBE_HOST = "www.apple.com"
DIAGNOSTIC_REPORTS_DIR = "~/Library/Logs/DiagnosticReports"
_LOG_SHOW_MAX_LINES = 80
_CRASH_REPORT_MAX = 3


def _run(cmd: list[str], *, timeout: float = _CMD_TIMEOUT) -> tuple[int, str]:
    """Run a read-only diagnostic command; (-1, "") on missing binary/timeout."""
    try:
        out = subprocess.run(cmd, capture_output=True, timeout=timeout)
        return out.returncode, out.stdout.decode("utf-8", errors="replace")
    except Exception:
        return -1, ""


# ---------------------------------------------------------------------------
# Network
# ---------------------------------------------------------------------------


def _default_route() -> tuple[str, str]:
    """(interface, gateway_ip) from `route -n get default`; empty strings when absent."""
    rc, out = _run(["route", "-n", "get", "default"], timeout=4.0)
    if rc != 0:
        return "", ""
    iface = ""
    gateway = ""
    for line in out.splitlines():
        s = line.strip()
        if s.startswith("interface:"):
            iface = s.split(":", 1)[1].strip()
        elif s.startswith("gateway:"):
            gateway = s.split(":", 1)[1].strip()
    return iface, gateway


def _physical_interfaces() -> list[dict]:
    """en* interfaces with their IPv4 addresses and up/down state (via psutil)."""
    try:
        import psutil

        addrs = psutil.net_if_addrs()
        stats = psutil.net_if_stats()
    except Exception:
        return []
    out: list[dict] = []
    for name, addr_list in addrs.items():
        if not name.startswith("en"):
            continue
        ipv4 = ""
        for a in addr_list:
            if a.family == socket.AF_INET:
                ipv4 = a.address
                break
        st = stats.get(name)
        out.append({"name": name, "ipv4": ipv4, "isup": bool(st.isup) if st is not None else False})
    out.sort(key=lambda d: d["name"])
    return out


def _wifi_power_on(iface: str) -> bool | None:
    rc, out = _run(["networksetup", "-getairportpower", iface], timeout=4.0)
    if rc != 0 or "Wi-Fi Power" not in out:
        return None
    return "): On" in out


def _router_for_interface(iface: str) -> str:
    rc, out = _run(["ipconfig", "getoption", iface, "router"], timeout=4.0)
    if rc != 0:
        return ""
    ip = out.strip()
    return ip if re.fullmatch(r"[0-9.]+", ip or "") else ""


def _dhcp_lease_present(iface: str) -> bool:
    rc, out = _run(["ipconfig", "getpacket", iface], timeout=4.0)
    return rc == 0 and "BOOTREPLY" in out


def _dns_resolvers() -> list[str]:
    rc, out = _run(["scutil", "--dns"], timeout=4.0)
    if rc != 0:
        return []
    seen: list[str] = []
    for m in re.finditer(r"nameserver\[\d+\]\s*:\s*([0-9a-fA-F.:]+)", out):
        ip = m.group(1)
        if ip not in seen:
            seen.append(ip)
    return seen[:6]


def _dns_resolves(host: str = "apple.com") -> bool:
    rc, out = _run(["dscacheutil", "-q", "host", "-a", "name", host], timeout=6.0)
    return rc == 0 and ("address:" in out or "_address" in out)


def _ping_ok(ip: str) -> bool:
    rc, _ = _run(["ping", "-c", "1", "-t", str(int(_PING_TIMEOUT)), ip], timeout=_PING_TIMEOUT + 2)
    return rc == 0


def _captive_portal_check() -> str:
    """'clear', 'portal', or 'unreachable' via Apple's hotspot-detect endpoint."""
    try:
        req = Request(CAPTIVE_PROBE_URL, headers={"User-Agent": "CaptiveNetworkSupport/1.0 wispr"})
        with urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
            body = resp.read(4096).decode("utf-8", errors="replace")
            if resp.status == 200 and "Success" in body:
                return "clear"
            return "portal"
    except Exception:
        return "unreachable"


def _external_https_ok() -> bool:
    try:
        req = Request(f"https://{EXTERNAL_PROBE_HOST}/", method="HEAD", headers={"User-Agent": "MugiDiagnostics/1.0"})
        with urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
            return 200 <= resp.status < 500
    except Exception:
        return False


def diagnose_network() -> dict:
    """Walk the connectivity layers and stop at the first broken one."""
    findings: list[str] = []
    route_iface, route_gateway = _default_route()
    vpn_active = route_iface.startswith(("utun", "ipsec", "ppp"))
    if vpn_active:
        findings.append(
            f"A VPN is active (default route via {route_iface}). If the VPN is the problem, "
            "disconnecting it may restore connectivity."
        )

    phys = _physical_interfaces()
    primary = None
    if route_iface and not vpn_active:
        primary = next((p for p in phys if p["name"] == route_iface), None)
    if primary is None:
        primary = next((p for p in phys if p["isup"] and p["ipv4"] and not p["ipv4"].startswith("169.254.")), None)
    if primary is None:
        primary = next((p for p in phys if p["ipv4"]), None) or (phys[0] if phys else None)

    # Layer 1: link + address
    if primary is None or not primary["isup"] or not primary["ipv4"]:
        iface_name = (primary or {}).get("name") or "en0"
        wifi_on = _wifi_power_on(iface_name)
        if wifi_on is False:
            findings.append(f"Wi-Fi power is OFF on {iface_name}.")
            verdict = "Wi-Fi is turned off. Turn it back on from the menu bar (or Settings → Wi-Fi) and try again."
        else:
            findings.append(f"No active network interface with an IP address (checked {iface_name}).")
            verdict = (
                "The Mac is not connected to any network — no interface has an address. "
                "Check that Wi-Fi is connected to the right network (or the Ethernet cable is plugged in)."
            )
        return {"verdict": verdict, "findings": findings, "layer": "link"}
    iface = primary["name"]
    ip = primary["ipv4"]
    findings.append(f"Interface {iface} is up with address {ip}.")

    # Layer 2: DHCP / self-assigned address
    if ip.startswith("169.254."):
        findings.append("Address is self-assigned (169.254.x.x) — the router's DHCP server never answered.")
        verdict = (
            "Connected to the network but the router never assigned an address (DHCP failure). "
            "Rebooting the router usually fixes this; renewing the DHCP lease may also help."
        )
        return {"verdict": verdict, "findings": findings, "layer": "dhcp"}
    if _dhcp_lease_present(iface):
        findings.append("DHCP lease looks valid.")

    # Layer 3: gateway reachability
    gateway = route_gateway if not vpn_active else ""
    if not gateway:
        gateway = _router_for_interface(iface)
    if gateway:
        if _ping_ok(gateway):
            findings.append(f"Router {gateway} responds to ping.")
        else:
            findings.append(f"Router {gateway} does NOT respond to ping.")
            verdict = (
                f"Connected to Wi-Fi but the router ({gateway}) is not responding. "
                "This is a local network problem — restart the router, or move closer if the signal is weak."
            )
            return {"verdict": verdict, "findings": findings, "layer": "gateway"}
    else:
        findings.append("Could not determine the router address (skipping router ping).")

    # Layer 4: DNS
    resolvers = _dns_resolvers()
    if resolvers:
        findings.append("DNS servers: " + ", ".join(resolvers))
    if not _dns_resolves():
        verdict = (
            "The network is up but DNS lookups are failing"
            + (f" (servers: {', '.join(resolvers)})" if resolvers else "")
            + ". Websites won't load by name. Try switching DNS to 1.1.1.1 / 8.8.8.8 in "
            "Settings → Wi-Fi → Details → DNS, or restart the router."
        )
        findings.append("DNS lookup for apple.com FAILED.")
        return {"verdict": verdict, "findings": findings, "layer": "dns"}
    findings.append("DNS lookups work (apple.com resolves).")

    # Layer 5: captive portal
    captive = _captive_portal_check()
    if captive == "portal":
        findings.append("Captive-portal probe returned a login page instead of the expected response.")
        verdict = (
            "This network wants you to sign in first (hotel/café-style login page). "
            "Open Safari and any http:// page to trigger the login screen."
        )
        return {"verdict": verdict, "findings": findings, "layer": "captive_portal"}
    if captive == "unreachable":
        findings.append("Captive-portal probe could not reach the internet.")

    # Layer 6: external reachability
    if _external_https_ok():
        findings.append(f"HTTPS to {EXTERNAL_PROBE_HOST} works.")
        verdict = (
            "Everything checks out: link, address, router, DNS, and external HTTPS all work. "
            "The internet connection looks healthy right now — if a specific site or app is failing, "
            "the problem is likely on their end (or app-specific)."
        )
        return {"verdict": verdict, "findings": findings, "layer": "ok"}
    findings.append(f"HTTPS to {EXTERNAL_PROBE_HOST} FAILED.")
    verdict = (
        "Local network and DNS are fine, but traffic to the wider internet is failing. "
        "That points at the ISP/modem (or an active VPN/firewall). Restart the modem; "
        "if a VPN is on, try disconnecting it."
    )
    return {"verdict": verdict, "findings": findings, "layer": "external"}


# ---------------------------------------------------------------------------
# App launch / crash diagnostics
# ---------------------------------------------------------------------------


def _normalize_app_name(name: str) -> str:
    return re.sub(r"[^a-z0-9]", "", (name or "").lower())


def _crash_reports_for(app_name: str, *, max_age_hours: float = 24.0) -> list[dict]:
    """Newest crash reports whose filename or header app_name matches (fuzzy)."""
    root = os.path.expanduser(DIAGNOSTIC_REPORTS_DIR)
    want = _normalize_app_name(app_name)
    if not want:
        return []
    try:
        names = os.listdir(root)
    except Exception:
        return []
    cutoff = time.time() - max_age_hours * 3600.0
    candidates: list[tuple[float, str]] = []
    for fn in names:
        if not fn.endswith(".ips"):
            continue
        path = os.path.join(root, fn)
        try:
            mtime = os.path.getmtime(path)
        except OSError:
            continue
        if mtime < cutoff:
            continue
        if want in _normalize_app_name(fn.rsplit("-", 4)[0]):
            candidates.append((mtime, path))
    candidates.sort(reverse=True)

    reports: list[dict] = []
    for _mtime, path in candidates[:_CRASH_REPORT_MAX]:
        parsed = _parse_ips_report(path)
        if parsed:
            reports.append(parsed)
    return reports


def _parse_ips_report(path: str) -> dict | None:
    """Best-effort parse of a macOS .ips crash report (JSON header + JSON body)."""
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            raw = f.read(400_000)
    except OSError:
        return None
    try:
        nl = raw.index("\n")
        header = json.loads(raw[:nl])
    except Exception:
        return None
    out = {
        "file": os.path.basename(path),
        "app_name": str(header.get("app_name") or ""),
        "timestamp": str(header.get("timestamp") or ""),
        "bug_type": str(header.get("bug_type") or ""),
        "termination": "",
        "exception_type": "",
    }
    try:
        body = json.loads(raw[nl + 1 :])
        term = body.get("termination") or {}
        if isinstance(term, dict):
            bits = [str(term.get("namespace") or ""), str(term.get("indicator") or "")]
            out["termination"] = " ".join(b for b in bits if b).strip()
        exc = body.get("exception") or {}
        if isinstance(exc, dict):
            out["exception_type"] = str(exc.get("type") or "")
    except Exception:
        pass
    return out


def _sanitize_log_predicate_value(name: str) -> str:
    return re.sub(r'["\\\']', "", (name or "").strip())[:80]


def _recent_log_errors(app_name: str, since_minutes: int) -> list[str]:
    name = _sanitize_log_predicate_value(app_name)
    if not name:
        return []
    minutes = max(1, min(120, int(since_minutes)))
    rc, out = _run(
        [
            "/usr/bin/log",
            "show",
            "--last",
            f"{minutes}m",
            "--predicate",
            f'process CONTAINS[c] "{name}"',
            "--level",
            "error",
            "--style",
            "compact",
        ],
        timeout=20.0,
    )
    if rc != 0:
        return []
    lines = [ln for ln in out.splitlines() if ln.strip() and not ln.startswith("Timestamp")]
    return lines[-_LOG_SHOW_MAX_LINES:]


def diagnose_app(app_name: str, since_minutes: int = 10) -> dict:
    findings: list[str] = []
    name = (app_name or "").strip()
    if not name:
        return {
            "verdict": "No app name given — ask the user which app is misbehaving, then run this again with target set.",
            "findings": [],
            "crash_reports": [],
        }
    reports = _crash_reports_for(name)
    for r in reports:
        desc = f"Crash report {r['file']} at {r['timestamp']}"
        extra = ", ".join(x for x in (r["termination"], r["exception_type"]) if x)
        if extra:
            desc += f" ({extra})"
        findings.append(desc)
    log_lines = _recent_log_errors(name, since_minutes)
    if log_lines:
        findings.append(f"{len(log_lines)} recent error-level log line(s) from processes matching '{name}'.")

    if reports:
        newest = reports[0]
        reason = ", ".join(x for x in (newest["termination"], newest["exception_type"]) if x) or "see report"
        verdict = (
            f"{newest['app_name'] or name} crashed at {newest['timestamp']} ({reason}). "
            "Common fixes: update or reinstall the app; if it crashes on launch every time, "
            "its preferences or cache may be corrupt."
        )
    elif log_lines:
        verdict = (
            f"No crash report for {name} in the last 24h, but it is logging errors — "
            "see the log excerpt below for the failing subsystem."
        )
    else:
        verdict = (
            f"No crash reports or recent error logs found for '{name}'. "
            "Ask the user to launch the app once more right now, then run this diagnosis again "
            "so the fresh failure is captured."
        )
    return {"verdict": verdict, "findings": findings, "crash_reports": reports, "log_excerpt": log_lines[-12:]}


# ---------------------------------------------------------------------------
# Printing
# ---------------------------------------------------------------------------


def _parse_lpstat_printers(text: str) -> list[dict]:
    """Parse `lpstat -p -d`: printer states + system default."""
    printers: list[dict] = []
    default = ""
    current: dict | None = None
    for line in text.splitlines():
        m = re.match(r"printer (\S+) (is idle|now printing|disabled)(.*)", line.strip())
        if m:
            current = {
                "name": m.group(1),
                "state": m.group(2),
                "disabled": m.group(2) == "disabled",
                "reasons": [],
            }
            printers.append(current)
            continue
        s = line.strip()
        if s.startswith("system default destination:"):
            default = s.split(":", 1)[1].strip()
        elif current is not None and s and not s.startswith("printer "):
            current["reasons"].append(s[:200])
    for p in printers:
        p["is_default"] = p["name"] == default
    return printers


def _parse_lpstat_jobs(text: str) -> list[dict]:
    """Parse `lpstat -W not-completed -o`: queued jobs."""
    jobs: list[dict] = []
    for line in text.splitlines():
        parts = line.split()
        if len(parts) >= 4 and "-" in parts[0]:
            jobs.append({"job": parts[0], "user": parts[1], "submitted": " ".join(parts[3:])[:64]})
    return jobs


def _printer_host_from_uri(uri: str) -> str:
    """Pingable host from a CUPS device URI; '' for usb:// / dnssd:// (not pingable by name)."""
    m = re.match(r"(ipps?|socket|lpd|http|https)://([^/:?]+)", uri or "")
    return m.group(2) if m else ""


def diagnose_printing(printer: str = "") -> dict:
    findings: list[str] = []
    rc, ptext = _run(["lpstat", "-p", "-d"], timeout=6.0)
    if rc != 0:
        return {
            "verdict": "Could not query the print system (CUPS may be stopped). "
            "Try: System Settings → Printers, or restart the Mac's print system.",
            "findings": ["lpstat failed — CUPS scheduler may not be running."],
        }
    printers = _parse_lpstat_printers(ptext)
    if not printers:
        return {
            "verdict": "No printers are configured on this Mac. Add one in System Settings → Printers & Scanners.",
            "findings": ["lpstat reports no printers."],
        }

    want = _normalize_app_name(printer)
    target = next((p for p in printers if want and want in _normalize_app_name(p["name"])), None)
    if target is None:
        target = next((p for p in printers if p["is_default"]), printers[0])
    findings.append(f"Printer {target['name']}: {target['state']}" + (" (default)" if target["is_default"] else ""))
    for r in target["reasons"]:
        findings.append(f"  reason: {r}")

    rc, jtext = _run(["lpstat", "-W", "not-completed", "-o"], timeout=6.0)
    jobs = _parse_lpstat_jobs(jtext) if rc == 0 else []
    mine = [j for j in jobs if j["job"].rsplit("-", 1)[0] == target["name"]] or jobs
    if mine:
        findings.append(f"{len(mine)} job(s) waiting in the queue; oldest: {mine[0]['job']} ({mine[0]['submitted']}).")

    host = ""
    rc, vtext = _run(["lpstat", "-v"], timeout=6.0)
    if rc == 0:
        for line in vtext.splitlines():
            m = re.match(rf"device for {re.escape(target['name'])}:\s*(\S+)", line.strip())
            if m:
                host = _printer_host_from_uri(m.group(1))
                if not host:
                    findings.append(f"Printer connection: {m.group(1).split('://')[0]}:// (no direct host to ping).")
    reachable = None
    if host:
        reachable = _ping_ok(host)
        findings.append(f"Printer host {host} {'responds to ping' if reachable else 'does NOT respond to ping'}.")

    if target["disabled"]:
        verdict = (
            f"The print queue for {target['name']} is PAUSED — jobs pile up and nothing prints. "
            f"Fix: resume it (System Settings → Printers, or `cupsenable {target['name']}` with your approval)."
        )
    elif reachable is False:
        verdict = (
            f"The printer {target['name']} is unreachable on the network ({host} not responding). "
            "Check that it is powered on and on the same Wi-Fi, then try again."
        )
    elif mine:
        verdict = (
            f"{len(mine)} job(s) are stuck in the queue for {target['name']} (oldest: {mine[0]['job']}). "
            f"A stuck job blocks everything behind it — cancelling it (`cancel {mine[0]['job']}`, with your approval) "
            "usually unjams the queue."
        )
    else:
        verdict = (
            f"{target['name']} looks healthy: queue active, no stuck jobs"
            + (", printer reachable" if reachable else "")
            + ". Ask the user to print again right now, then re-run this to catch the failure live."
        )
    return {"verdict": verdict, "findings": findings, "printers": printers, "jobs": mine}


# ---------------------------------------------------------------------------
# Power / sleep
# ---------------------------------------------------------------------------


def _system_profiler_json(datatype: str) -> dict:
    """First entry of `system_profiler <datatype> -json`; {} on any failure."""
    rc, out = _run(["system_profiler", datatype, "-json"], timeout=10.0)
    if rc != 0 or not out.strip():
        return {}
    try:
        items = json.loads(out).get(datatype) or []
        return items[0] if items and isinstance(items[0], dict) else {}
    except Exception:
        return {}


_SLEEP_ASSERTION_TYPES = (
    "PreventUserIdleSystemSleep",
    "PreventSystemSleep",
    "NoIdleSleepAssertion",
    "PreventUserIdleDisplaySleep",
)
# powerd's own bookkeeping assertion is always present while the display is on.
_ASSERTION_OWNER_IGNORE = ("powerd",)


def _battery_status() -> dict:
    """Parse `pmset -g batt`: power source, percent, charging state."""
    rc, out = _run(["pmset", "-g", "batt"], timeout=4.0)
    if rc != 0:
        return {}
    result: dict = {}
    m = re.search(r"Now drawing from '([^']+)'", out)
    if m:
        result["source"] = m.group(1)
    m = re.search(r"(\d{1,3})%;\s*([^;]+);?", out)
    if m:
        result["percent"] = int(m.group(1))
        result["state"] = m.group(2).strip()
    return result


def _sleep_blockers() -> list[dict]:
    """Apps holding sleep assertions, from `pmset -g assertions`."""
    rc, out = _run(["pmset", "-g", "assertions"], timeout=4.0)
    if rc != 0:
        return []
    blockers: list[dict] = []
    for m in re.finditer(r'pid (\d+)\((.+?)\):\s*\[[^\]]*\]\s*[\d:]+\s+(\w+)\s+named:\s*"([^"]*)"', out):
        pid, owner, kind, label = m.group(1), m.group(2), m.group(3), m.group(4)
        if kind not in _SLEEP_ASSERTION_TYPES or owner in _ASSERTION_OWNER_IGNORE:
            continue
        blockers.append({"pid": int(pid), "owner": owner, "kind": kind, "label": label[:120]})
    return blockers


def diagnose_power() -> dict:
    findings: list[str] = []
    batt = _battery_status()
    has_battery = "percent" in batt
    if has_battery:
        findings.append(
            f"Battery at {batt['percent']}% ({batt.get('state', '?')}), on {batt.get('source', '?')}."
        )

    health = {}
    info = _system_profiler_json("SPPowerDataType")
    if info.get("_name") == "spbattery_information":
        health = info.get("sppower_battery_health_info") or {}
        charge = info.get("sppower_battery_charge_info") or {}
    else:
        charge = {}
    cycle_count = health.get("sppower_battery_cycle_count")
    condition = str(health.get("sppower_battery_health") or "")
    max_cap_raw = str(health.get("sppower_battery_health_maximum_capacity") or "")
    max_cap = None
    m = re.match(r"(\d{1,3})%", max_cap_raw)
    if m:
        max_cap = int(m.group(1))
    if condition:
        cap_note = f", maximum capacity {max_cap}%" if max_cap is not None else ""
        findings.append(f"Battery health: {condition} ({cycle_count} cycles{cap_note}).")

    blockers = _sleep_blockers()
    for b in blockers:
        findings.append(f"{b['owner']} (pid {b['pid']}) holds a {b['kind']} assertion: \"{b['label']}\".")

    unhealthy_battery = has_battery and (
        (condition and condition.lower() not in ("good", "normal")) or (max_cap is not None and max_cap < 80)
    )
    on_ac = "AC" in str(batt.get("source") or "")
    state = str(batt.get("state") or "").lower()
    not_charging_on_ac = (
        has_battery
        and on_ac
        and batt.get("percent", 100) < 90
        and "charging" not in state  # "charging" / "finishing charge" are fine; "discharging" is not
        or (has_battery and on_ac and "discharging" in state)
    )

    if unhealthy_battery:
        verdict = (
            f"The battery itself is the problem: condition \"{condition}\""
            + (f", maximum capacity {max_cap}%" if max_cap is not None else "")
            + f" after {cycle_count} cycles. It holds less charge than it used to; "
            "Apple service can replace it (Settings → Battery shows the same warning)."
        )
    elif not_charging_on_ac:
        verdict = (
            f"Plugged in but not charging (battery at {batt.get('percent')}%). "
            "Try another cable/adapter and port; note that Optimized Battery Charging can also "
            "hold charging intentionally (Settings → Battery)."
        )
    elif blockers:
        names = ", ".join(sorted({b["owner"] for b in blockers}))
        verdict = (
            f"The Mac is being kept awake by: {names}. "
            "Quit (or finish the work in) those apps and it will sleep normally. "
            "If one of them is stuck, I can propose a command to quit it — with your approval."
        )
    else:
        bits = []
        if has_battery:
            bits.append(f"battery {batt.get('percent')}% ({condition or 'condition unknown'})")
        bits.append("nothing is blocking sleep")
        verdict = "Power looks healthy: " + ", ".join(bits) + "."
    return {"verdict": verdict, "findings": findings, "blockers": blockers}


# ---------------------------------------------------------------------------
# Bluetooth
# ---------------------------------------------------------------------------


def _bt_device_list(raw: object) -> list[dict]:
    """Flatten SPBluetoothDataType device lists: [{name: {props}}] → [{name, battery, type}]."""
    out: list[dict] = []
    if not isinstance(raw, list):
        return out
    for entry in raw:
        if not isinstance(entry, dict):
            continue
        for name, props in entry.items():
            if not isinstance(props, dict):
                continue
            battery = ""
            for k, v in props.items():
                if "batterylevel" in k.lower():
                    battery = f"{v}".strip()
                    if battery and not battery.endswith("%"):
                        battery += "%"
                    break
            out.append(
                {
                    "name": str(name)[:80],
                    "battery": battery,
                    "type": str(props.get("device_minorType") or "")[:40],
                }
            )
    return out


def diagnose_bluetooth(target: str = "") -> dict:
    findings: list[str] = []
    info = _system_profiler_json("SPBluetoothDataType")
    if not info:
        return {
            "verdict": "Could not read Bluetooth status from the system. Settings → Bluetooth is the next stop.",
            "findings": ["system_profiler returned no Bluetooth data."],
        }
    controller = info.get("controller_properties") or {}
    powered_on = str(controller.get("controller_state") or "") == "attrib_on"
    connected = _bt_device_list(info.get("device_connected"))
    paired = _bt_device_list(info.get("device_not_connected"))

    findings.append(f"Bluetooth is {'on' if powered_on else 'OFF'}.")
    for d in connected:
        findings.append(f"Connected: {d['name']}" + (f" (battery {d['battery']})" if d["battery"] else ""))
    if paired:
        findings.append("Paired but not connected: " + ", ".join(d["name"] for d in paired[:8]) + ".")

    if not powered_on:
        verdict = "Bluetooth is turned off — that's why nothing connects. Turn it on in Control Center or Settings → Bluetooth."
        return {"verdict": verdict, "findings": findings, "connected": connected, "paired": paired}

    want = _normalize_app_name(target)
    if want:
        hit = next((d for d in connected if want in _normalize_app_name(d["name"])), None)
        if hit:
            verdict = (
                f"{hit['name']} is connected"
                + (f" (battery {hit['battery']})" if hit["battery"] else "")
                + ". If it still isn't working, the problem is selection, not connection — e.g. "
                "pick it as the sound output, or check its batteries/charge."
            )
            return {"verdict": verdict, "findings": findings, "connected": connected, "paired": paired}
        hit = next((d for d in paired if want in _normalize_app_name(d["name"])), None)
        if hit:
            is_headphones = "headphone" in (hit["type"] or "").lower()
            extra = (
                " For AirPods: put them in the case, open the lid next to the Mac, then reconnect."
                if is_headphones
                else " Make sure it is powered on, charged, and within range, then click Connect in Settings → Bluetooth."
            )
            verdict = f"{hit['name']} is paired with this Mac but NOT currently connected.{extra}"
            return {"verdict": verdict, "findings": findings, "connected": connected, "paired": paired}
        verdict = (
            f"This Mac doesn't know a device called \"{target}\" — it has never been paired (or was removed). "
            "Put the device in pairing mode and add it in Settings → Bluetooth."
        )
        return {"verdict": verdict, "findings": findings, "connected": connected, "paired": paired}

    if connected:
        verdict = (
            f"Bluetooth is on with {len(connected)} device(s) connected. "
            "If a specific device is misbehaving, tell me which one and I'll check it."
        )
    else:
        verdict = (
            "Bluetooth is on but nothing is connected right now. "
            "Which device are you trying to use? I can check whether the Mac knows it."
        )
    return {"verdict": verdict, "findings": findings, "connected": connected, "paired": paired}


# ---------------------------------------------------------------------------
# Audio
# ---------------------------------------------------------------------------


def _volume_settings() -> dict:
    """Parse `osascript -e 'get volume settings'` → {volume: int|None, muted: bool|None}."""
    rc, out = _run(["osascript", "-e", "get volume settings"], timeout=6.0)
    if rc != 0:
        return {"volume": None, "muted": None}
    vol = None
    m = re.search(r"output volume:(\d+)", out)
    if m:
        vol = int(m.group(1))
    muted = None
    m = re.search(r"output muted:(true|false)", out)
    if m:
        muted = m.group(1) == "true"
    return {"volume": vol, "muted": muted}


def diagnose_audio() -> dict:
    findings: list[str] = []
    info = _system_profiler_json("SPAudioDataType")
    items = info.get("_items") or []
    default_out = None
    outputs = []
    for it in items:
        if not isinstance(it, dict):
            continue
        if it.get("coreaudio_device_output"):
            outputs.append(it)
            if str(it.get("coreaudio_default_audio_output_device") or "") == "spaudio_yes":
                default_out = it
    vol = _volume_settings()
    if default_out is not None:
        findings.append(f"Default sound output: {default_out.get('_name', '?')}.")
    if vol["volume"] is not None:
        findings.append(f"Output volume {vol['volume']}/100" + (", MUTED" if vol["muted"] else "") + ".")

    if vol["muted"]:
        verdict = "Sound output is muted — that's the whole problem. Press F12 / tap the speaker icon to unmute."
    elif vol["volume"] == 0:
        verdict = "Output volume is at 0. Turn it up (F12 or the volume slider in Control Center)."
    elif default_out is None and outputs:
        verdict = (
            "No device is selected as the sound output. Pick one under Control Center → Sound "
            "(or Settings → Sound → Output)."
        )
    elif default_out is None:
        verdict = "No sound output devices were found at all — that points at an audio system problem; a restart usually clears it."
    else:
        transport = str(default_out.get("coreaudio_device_transport") or "")
        name = str(default_out.get("_name") or "the selected device")
        if transport == "coreaudio_device_type_virtual":
            verdict = (
                f"Sound is routed to \"{name}\", a virtual audio device (screen-recording/loopback software), "
                "not the speakers. Switch the output back in Control Center → Sound."
            )
        elif "bluetooth" in transport:
            verdict = (
                f"Sound is going to \"{name}\" over Bluetooth — not the Mac's speakers. "
                "If that's not what you want, change the output in Control Center → Sound."
            )
        else:
            verdict = (
                f"Audio looks healthy: output is \"{name}\" at volume {vol['volume'] if vol['volume'] is not None else '?'}/100, not muted. "
                "If a single app is silent, check that app's own volume/mute."
            )
    return {"verdict": verdict, "findings": findings, "default_output": (default_out or {}).get("_name", "")}


# ---------------------------------------------------------------------------
# Displays
# ---------------------------------------------------------------------------


def _displays_list() -> list[dict]:
    """Flatten SPDisplaysDataType across GPUs → [{name, online, main, mirror_on, internal, resolution}]."""
    rc, out = _run(["system_profiler", "SPDisplaysDataType", "-json"], timeout=10.0)
    if rc != 0 or not out.strip():
        return []
    try:
        gpus = json.loads(out).get("SPDisplaysDataType") or []
    except Exception:
        return []
    displays: list[dict] = []
    for gpu in gpus:
        for d in (gpu or {}).get("spdisplays_ndrvs") or []:
            if not isinstance(d, dict):
                continue
            displays.append(
                {
                    "name": str(d.get("_name") or "?")[:80],
                    "online": str(d.get("spdisplays_online") or "") == "spdisplays_yes",
                    "main": str(d.get("spdisplays_main") or "") == "spdisplays_yes",
                    "mirror_on": str(d.get("spdisplays_mirror") or "spdisplays_off") != "spdisplays_off",
                    "internal": "internal" in str(d.get("spdisplays_connection_type") or ""),
                    "resolution": str(d.get("_spdisplays_resolution") or d.get("spdisplays_resolution") or "")[:60],
                }
            )
    return displays


def diagnose_displays(target: str = "") -> dict:
    findings: list[str] = []
    displays = _displays_list()
    if not displays:
        return {
            "verdict": "Could not read display information from the system.",
            "findings": ["system_profiler returned no display data."],
        }
    for d in displays:
        bits = [d["name"], "built-in" if d["internal"] else "external"]
        if d["resolution"]:
            bits.append(d["resolution"])
        if d["mirror_on"]:
            bits.append("MIRRORED")
        findings.append(" — ".join(bits))

    externals = [d for d in displays if not d["internal"] and d["online"]]
    want = _normalize_app_name(target)
    if want and externals and not any(want in _normalize_app_name(d["name"]) for d in externals):
        seen = ", ".join(d["name"] for d in externals)
        verdict = (
            f"The Mac doesn't see a display called \"{target}\" (it does see: {seen}). "
            "Check that monitor's cable, input source, and power."
        )
    elif not externals:
        verdict = (
            "The Mac detects only its built-in display — the external monitor isn't being seen at all. "
            "Reseat both ends of the cable, try a different port/adapter, and make sure the monitor "
            "is on the right input. A different cable is the most common fix."
        )
    elif any(d["mirror_on"] for d in displays):
        verdict = (
            "The external monitor IS detected, but mirroring is on — both screens show the same image. "
            "Change it in Settings → Displays (uncheck \"Mirror\" / arrange as separate displays)."
        )
    else:
        names = ", ".join(f"{d['name']} ({d['resolution']})" for d in externals if d["resolution"]) or ", ".join(
            d["name"] for d in externals
        )
        verdict = (
            f"Displays look healthy: external {names} detected and extended. "
            "If the picture is wrong (blurry/flicker), the resolution or cable is the next suspect."
        )
    return {"verdict": verdict, "findings": findings, "displays": displays}


# ---------------------------------------------------------------------------
# Storage / disk space
# ---------------------------------------------------------------------------

_BIG_FILE_MIN_BYTES = 1 << 30  # 1 GiB
_BIG_FILE_MAX = 10
_DU_DIR_TIMEOUT = 5.0
_DU_TOTAL_BUDGET = 12.0
# Curated "usual suspect" directories — each is commonly large and safe to size.
_STORAGE_SUSPECT_DIRS = (
    "~/Downloads",
    "~/Movies",
    "~/.Trash",
    "~/Library/Caches",
    "~/Library/Developer",  # Xcode DerivedData + simulators
)
# Docker Desktop's disk image: one stat call instead of a slow du over Containers.
_DOCKER_RAW_PATHS = (
    "~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw",
    "~/Library/Containers/com.docker.docker/Data/vms/0/Docker.raw",
)


def _fmt_gb(num_bytes: float) -> str:
    gb = num_bytes / (1 << 30)
    if gb < 1:
        return f"{num_bytes / (1 << 20):.0f} MB"
    return f"{gb:.1f} GB" if gb >= 10 else f"{gb:.2f} GB"


def _home_relative(path: str) -> str:
    home = os.path.expanduser("~")
    return "~" + path[len(home):] if path.startswith(home) else path


def _root_capacity() -> dict:
    try:
        import psutil

        u = psutil.disk_usage("/")
        # Don't trust u.percent on APFS: "/" is the sealed system snapshot, whose
        # `used` ignores the data volume. free/total are container-wide and real.
        percent = (1 - u.free / u.total) * 100 if u.total else 0.0
        return {"total": int(u.total), "free": int(u.free), "percent": percent}
    except Exception:
        return {}


def _local_snapshot_count() -> int:
    rc, out = _run(["tmutil", "listlocalsnapshots", "/"], timeout=4.0)
    if rc != 0:
        return 0
    return sum(1 for line in out.splitlines() if line.strip().startswith("com.apple.TimeMachine"))


def _spotlight_big_files(min_bytes: int = _BIG_FILE_MIN_BYTES, limit: int = _BIG_FILE_MAX) -> list[dict]:
    """Largest files under the home folder via the Spotlight index (fast, no scan)."""
    rc, out = _run(
        ["mdfind", f"kMDItemFSSize > {min_bytes}", "-onlyin", os.path.expanduser("~")],
        timeout=10.0,
    )
    if rc != 0:
        return []
    sized: list[dict] = []
    for path in out.splitlines():
        path = path.strip()
        if not path:
            continue
        try:
            sized.append({"path": path, "bytes": os.path.getsize(path)})
        except OSError:
            continue
    sized.sort(key=lambda f: -f["bytes"])
    return sized[:limit]


def _suspect_dir_sizes() -> list[dict]:
    """du -sk over the curated suspect dirs, bounded per-dir and overall."""
    results: list[dict] = []
    deadline = time.monotonic() + _DU_TOTAL_BUDGET
    for raw in _STORAGE_SUSPECT_DIRS:
        path = os.path.expanduser(raw)
        if not os.path.isdir(path) or time.monotonic() >= deadline:
            continue
        rc, out = _run(["du", "-sk", path], timeout=min(_DU_DIR_TIMEOUT, max(0.5, deadline - time.monotonic())))
        m = re.match(r"(\d+)\s", out or "")
        if rc != 0 or not m:
            continue
        results.append({"path": raw, "bytes": int(m.group(1)) * 1024})
    for raw in _DOCKER_RAW_PATHS:
        path = os.path.expanduser(raw)
        try:
            results.append({"path": "Docker disk image", "bytes": os.path.getsize(path)})
            break
        except OSError:
            continue
    results.sort(key=lambda d: -d["bytes"])
    return results


def _dir_breakdown(target: str) -> list[dict]:
    """One-level `du -k -d 1` breakdown of a folder, largest children first."""
    path = os.path.expanduser(target)
    rc, out = _run(["du", "-k", "-d", "1", path], timeout=15.0)
    if rc != 0 and not out.strip():
        return []
    children: list[dict] = []
    for line in out.splitlines():
        m = re.match(r"(\d+)\s+(.+)", line.strip())
        if not m:
            continue
        child = m.group(2)
        if os.path.abspath(child) == os.path.abspath(path):
            continue  # du's trailing total line
        children.append({"path": child, "bytes": int(m.group(1)) * 1024})
    children.sort(key=lambda d: -d["bytes"])
    return children[:10]


def diagnose_storage(target: str = "") -> dict:
    findings: list[str] = []

    if target.strip():
        path = os.path.expanduser(target.strip())
        if not os.path.isdir(path):
            return {
                "verdict": f"\"{target}\" isn't a folder I can measure. Give me a folder path (e.g. ~/Movies).",
                "findings": [],
            }
        children = _dir_breakdown(target.strip())
        if not children:
            return {
                "verdict": f"Couldn't measure inside {target} (permissions or it took too long).",
                "findings": [],
            }
        for c in children:
            findings.append(f"{_fmt_gb(c['bytes'])}  {_home_relative(c['path'])}")
        top = children[0]
        verdict = (
            f"Inside {target}, the biggest item is {_home_relative(top['path'])} at {_fmt_gb(top['bytes'])}. "
            "Tell me to go deeper into any of these, or which ones you'd consider removing."
        )
        return {"verdict": verdict, "findings": findings, "children": children}

    cap = _root_capacity()
    if not cap:
        return {"verdict": "Could not read disk capacity from the system.", "findings": []}
    free_gb = cap["free"] / (1 << 30)
    findings.append(
        f"Startup disk: {_fmt_gb(cap['free'])} free of {_fmt_gb(cap['total'])} ({cap['percent']:.0f}% used). "
        "Finder may show more available because macOS counts purgeable space."
    )

    big_files = _spotlight_big_files()
    for f in big_files[:6]:
        findings.append(f"{_fmt_gb(f['bytes'])}  {_home_relative(f['path'])}")
    suspects = _suspect_dir_sizes()
    if any(f["path"].endswith("Docker.raw") for f in big_files):
        suspects = [d for d in suspects if d["path"] != "Docker disk image"]
    for d in suspects[:4]:
        findings.append(f"{_fmt_gb(d['bytes'])}  {d['path']} (folder total)")
    snapshots = _local_snapshot_count()
    if snapshots:
        findings.append(
            f"{snapshots} local Time Machine snapshot(s) on disk — this is why freed space "
            "(emptied Trash, deleted files) may not show up immediately; macOS purges them under pressure."
        )

    hogs = ""
    if big_files:
        hogs = "; ".join(f"{_home_relative(f['path'])} ({_fmt_gb(f['bytes'])})" for f in big_files[:3])
    elif suspects:
        hogs = "; ".join(f"{d['path']} ({_fmt_gb(d['bytes'])})" for d in suspects[:3])

    # Absolute headroom matters more than percent: 87% of a 4 TB disk is still
    # hundreds of GB free, while 10 GB free cripples macOS regardless of percent.
    if free_gb < 10:
        verdict = (
            f"The startup disk is critically full — only {_fmt_gb(cap['free'])} free. "
            + (f"The biggest things I can see: {hogs}. " if hogs else "")
            + "Moving or deleting the top one or two frees the most; I can drill into any folder "
            "(ask me to look inside it) before you remove anything."
        )
    elif free_gb < 50:
        verdict = (
            f"The disk is getting full ({cap['percent']:.0f}% used, {_fmt_gb(cap['free'])} free) but not critical yet. "
            + (f"Biggest space users: {hogs}." if hogs else "")
        )
    else:
        verdict = (
            f"Disk space looks fine: {_fmt_gb(cap['free'])} free ({100 - cap['percent']:.0f}% available). "
            + (f"For reference, the biggest space users are: {hogs}." if hogs else "")
        )
    return {
        "verdict": verdict,
        "findings": findings,
        "capacity": cap,
        "big_files": big_files,
        "suspect_dirs": suspects,
        "snapshots": snapshots,
    }


# ---------------------------------------------------------------------------
# Rendering + dispatch
# ---------------------------------------------------------------------------


def _render(result: dict, *, title: str) -> str:
    lines = [title]
    for f in result.get("findings") or []:
        lines.append(f"- {f}")
    excerpt = result.get("log_excerpt") or []
    if excerpt:
        lines.append("Recent error log excerpt:")
        lines.extend(f"  {ln[:220]}" for ln in excerpt)
    lines.append("")
    lines.append(f"Verdict: {result.get('verdict', '')}")
    return "\n".join(lines)


def diagnose(domain: str, target: str = "", since_minutes: int = 10) -> str:
    d = (domain or "").strip().lower()
    if d in ("network", "internet", "wifi"):
        return _render(diagnose_network(), title="Network diagnosis (checked just now, layer by layer):")
    if d == "app":
        return _render(diagnose_app(target, since_minutes=since_minutes), title=f"App diagnosis for '{target}':")
    if d in ("printing", "printer", "print"):
        return _render(diagnose_printing(target), title="Printing diagnosis:")
    if d in ("power", "battery", "sleep"):
        return _render(diagnose_power(), title="Power & sleep diagnosis:")
    if d == "bluetooth":
        return _render(diagnose_bluetooth(target), title="Bluetooth diagnosis:")
    if d in ("audio", "sound"):
        return _render(diagnose_audio(), title="Audio diagnosis:")
    if d in ("displays", "display", "monitor"):
        return _render(diagnose_displays(target), title="Display diagnosis:")
    if d in ("storage", "disk", "space"):
        return _render(diagnose_storage(target), title="Storage diagnosis:")
    return (
        'Unknown domain. Use "network", "app" (target=app name), "printing", '
        '"power", "bluetooth" (target=device name), "audio", "displays", '
        'or "storage" (target optionally a folder path to drill into).'
    )
