# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""LAN service discovery — probe local network for self-hosted services.

Uses only stdlib (socket, http.client, json) so there are no extra dependencies.
Designed to pre-fill URLs in the Hearth (self-hosted) connect wizards.
"""

from __future__ import annotations

import http.client
import json
import logging
import socket
import struct
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Optional

logger = logging.getLogger(__name__)

# ── Service probe definitions ────────────────────────────────────────────────

_SERVICE_PROBES: Dict[str, dict] = {
    "jellyfin": {
        "ports": [8096, 8920],
        "path": "/System/Info/Public",
        "expect": "ServerName",
        "display": "Jellyfin",
    },
    "gitea": {
        "ports": [3000],
        "path": "/api/v1/settings/api",
        "expect": "max_response_items",
        "display": "Gitea / Forgejo",
    },
    "homeassistant": {
        "ports": [8123],
        "path": "/api/",
        "expect": "message",
        "display": "Home Assistant",
    },
    "nextcloud": {
        "ports": [443, 8080, 80],
        "path": "/status.php",
        "expect": "productname",
        "display": "Nextcloud",
    },
    "pihole": {
        "ports": [80, 443],
        "path": "/admin/api.php?summary",
        "expect": "domains_being_blocked",
        "display": "Pi-hole",
    },
    "joplin": {
        "ports": [22300],
        "path": "/api/ping",
        "expect": "",
        "display": "Joplin Server",
    },
    "mealie": {
        "ports": [9925, 9000],
        "path": "/api/app/about",
        "expect": "version",
        "display": "Mealie",
    },
}


# ── Subnet detection ─────────────────────────────────────────────────────────


def _get_local_ip() -> Optional[str]:
    """Determine our LAN IP by connecting to a non-routable address."""
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("10.255.255.255", 1))
            return s.getsockname()[0]
    except Exception:
        pass
    # Fallback: gethostbyname
    try:
        return socket.gethostbyname(socket.gethostname())
    except Exception:
        return None


def _get_subnet(local_ip: str) -> str:
    """Return the /24 prefix from an IPv4 address (e.g. '192.168.1.')."""
    parts = local_ip.rsplit(".", 1)
    return parts[0] + "."


def _parse_proc_route() -> Optional[str]:
    """Try to read the default gateway subnet from /proc/net/route (Linux)."""
    try:
        with open("/proc/net/route", "r") as f:
            for line in f.readlines()[1:]:
                fields = line.strip().split()
                if len(fields) >= 3 and fields[1] == "00000000":
                    # Gateway field is in hex, little-endian
                    gw_hex = fields[2]
                    gw_bytes = struct.pack("<I", int(gw_hex, 16))
                    gw_ip = socket.inet_ntoa(gw_bytes)
                    return _get_subnet(gw_ip)
    except Exception:
        pass
    return None


# ── Port + HTTP probing ──────────────────────────────────────────────────────


def _probe_port(ip: str, port: int, timeout: float) -> bool:
    """Return True if a TCP port is open."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(timeout)
        return s.connect_ex((ip, port)) == 0


def _http_check(ip: str, port: int, path: str, expect: str, timeout: float) -> Optional[dict]:
    """Make an HTTP GET and check the response for the expected key.

    Returns ``{"url": ..., "name": ...}`` on success, ``None`` on failure.
    """
    scheme = "https" if port == 443 else "http"
    try:
        if port == 443:
            import ssl

            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            conn = http.client.HTTPSConnection(ip, port, timeout=timeout, context=ctx)
        else:
            conn = http.client.HTTPConnection(ip, port, timeout=timeout)

        conn.request("GET", path, headers={"Accept": "application/json"})
        resp = conn.getresponse()

        if resp.status != 200:
            conn.close()
            return None

        body = resp.read(8192).decode("utf-8", errors="replace")
        conn.close()

        url = f"{scheme}://{ip}:{port}" if port not in (80, 443) else f"{scheme}://{ip}"

        # If no expect key, any 200 is a match
        if not expect:
            return {"url": url, "name": ""}

        try:
            data = json.loads(body)
            if expect in data:
                # Try to extract a friendly display name
                name = ""
                for key in ("ServerName", "productname", "name", "title"):
                    if key in data and isinstance(data[key], str):
                        name = data[key]
                        break
                return {"url": url, "name": name}
        except (json.JSONDecodeError, TypeError):
            pass
    except Exception:
        pass
    return None


def _probe_ip_for_service(
    ip: str, service_key: str, probe: dict, timeout: float
) -> Optional[dict]:
    """Check one IP for one service. Returns result dict or None."""
    for port in probe["ports"]:
        if not _probe_port(ip, port, timeout):
            continue
        result = _http_check(ip, port, probe["path"], probe["expect"], timeout)
        if result:
            result["service"] = service_key
            result["display"] = probe["display"]
            return result
    return None


# ── Public API ───────────────────────────────────────────────────────────────


def discover_lan_services(
    services: Optional[List[str]] = None,
    timeout: float = 0.8,
    subnet: Optional[str] = None,
) -> Dict[str, dict]:
    """Scan the local /24 subnet for known self-hosted services.

    Args:
        services: List of service keys to probe (default: all).
        timeout: TCP connect timeout in seconds per probe.
        subnet: Override subnet prefix (e.g. ``"192.168.1."``).

    Returns:
        Dict mapping service key → ``{"url": ..., "name": ..., "display": ...}``.
        Only services actually found are included.
    """
    if subnet is None:
        subnet = _parse_proc_route()
    if subnet is None:
        local_ip = _get_local_ip()
        if local_ip is None:
            logger.debug("Cannot determine local subnet — skipping LAN discovery")
            return {}
        subnet = _get_subnet(local_ip)
    else:
        local_ip = _get_local_ip()

    # Select probes
    if services:
        probes = {k: v for k, v in _SERVICE_PROBES.items() if k in services}
    else:
        probes = dict(_SERVICE_PROBES)

    if not probes:
        return {}

    # Build list of (ip, service_key, probe) tasks
    own_ip = local_ip or ""
    ips = [
        f"{subnet}{i}"
        for i in range(1, 255)
        if f"{subnet}{i}" != own_ip
    ]

    found: Dict[str, dict] = {}

    def _task(ip: str, svc_key: str, probe: dict):
        return _probe_ip_for_service(ip, svc_key, probe, timeout)

    with ThreadPoolExecutor(max_workers=32) as pool:
        futures = {}
        for ip in ips:
            for svc_key, probe in probes.items():
                if svc_key in found:
                    continue  # already found this service
                fut = pool.submit(_task, ip, svc_key, probe)
                futures[fut] = svc_key

        for fut in as_completed(futures):
            svc_key = futures[fut]
            if svc_key in found:
                continue
            try:
                result = fut.result()
                if result:
                    found[svc_key] = result
            except Exception:
                pass

    logger.debug("LAN discovery found %d service(s): %s", len(found), list(found.keys()))
    return found


def discover_single_service(service_key: str, timeout: float = 0.8) -> Optional[dict]:
    """Scan the LAN for a single service.

    Returns ``{"url": ..., "name": ..., "display": ...}`` or ``None``.
    """
    if service_key not in _SERVICE_PROBES:
        return None
    results = discover_lan_services(services=[service_key], timeout=timeout)
    return results.get(service_key)
