"""Network preparation layer for NexaOffice.

This module is intentionally safe-by-default: network access is disabled unless
a deployment config or admin policy provides a server URL. The desktop app remains
local-first and usable offline when no system config exists.

v2026.06.19.37 hardens future online mode for Windows Defender/SmartScreen and
third-party antivirus review:
- automatic connection only when a system deployment config explicitly enables it;
- blank URLs are treated as offline;
- plain HTTP is allowed only for localhost development;
- manual connection attempts are recorded in a small local audit log without
  tokens, passwords, request bodies, or full paths.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
import ipaddress
import json
import random
import socket
import time
import urllib.error
import urllib.parse
import urllib.request

from .constants import DATA_DIR
from .storage import load_json, save_json

NETWORK_AUDIT_PATH = DATA_DIR / "network_audit.json"
NETWORK_PREFLIGHT_PATH = DATA_DIR / "network_readiness_latest.json"
NETWORK_AUDIT_LIMIT = 200
NETWORK_AUDIT_MIN_WRITE_GAP = 2.0
_HEALTH_CACHE = {}
_AUDIT_DEDUP = {}
_AUDIT_LAST_WRITE = {"time": 0.0}


@dataclass
class NetworkConfig:
    enabled: bool = False
    offline_mode: bool = True
    base_url: str = ""
    workspace_id: str = ""
    api_token: str = ""
    timeout: float = 4.5
    allow_insecure_localhost: bool = True
    retry_count: int = 2
    retry_backoff_base: float = 0.16
    health_cache_ttl_ok: float = 60.0
    health_cache_ttl_fail: float = 18.0
    managed_by_system: bool = True
    user_configurable: bool = False
    auto_connect: bool = False
    sync_events_enabled: bool = False
    deployment_mode: str = "local"

    @property
    def normalized_base_url(self) -> str:
        return normalize_base_url(self.base_url)

    @property
    def can_connect(self) -> bool:
        return bool(self.enabled and not self.offline_mode and self.normalized_base_url)


def normalize_base_url(url: str) -> str:
    url = str(url or "").strip()
    if not url:
        return ""
    if not (url.startswith("http://") or url.startswith("https://")):
        url = "https://" + url
    parsed = urllib.parse.urlparse(url)
    if not parsed.netloc:
        return ""
    # Only origin is kept. Paths/query fragments are ignored for the configured base URL
    # so tokens are not accidentally stored in settings or diagnostics.
    return urllib.parse.urlunparse((parsed.scheme.lower(), parsed.netloc, "", "", "", "")).rstrip("/")


def is_loopback_host(hostname: str) -> bool:
    host = (hostname or "").strip().lower().strip("[]")
    if host in {"localhost", "ip6-localhost", "ip6-loopback"}:
        return True
    try:
        return ipaddress.ip_address(host).is_loopback
    except Exception:
        return False


def validate_base_url_policy(config: NetworkConfig):
    """Return (ok, status_key, detail) for the configured URL policy."""
    base = config.normalized_base_url
    if not base:
        return False, "server_sync_status_missing_url", ""
    parsed = urllib.parse.urlparse(base)
    scheme = (parsed.scheme or "").lower()
    if scheme == "https":
        return True, "server_sync_status_configured", ""
    if scheme == "http" and config.allow_insecure_localhost and is_loopback_host(parsed.hostname or ""):
        return True, "server_sync_status_configured", "localhost http"
    return False, "server_sync_status_insecure_http", parsed.hostname or ""


def sanitize_url_for_log(url: str) -> str:
    try:
        parsed = urllib.parse.urlparse(str(url or ""))
        if not parsed.scheme or not parsed.netloc:
            return ""
        return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, "", "", "", ""))
    except Exception:
        return ""


def audit_network_event(action: str, status: str, url: str = "", detail: str = "") -> None:
    """Append a small local network audit event without secrets.

    This helps future online builds explain to users/security tools what network
    activity happened, while avoiding tokens, headers, bodies, query strings,
    and full endpoint paths.
    """
    try:
        data = load_json(NETWORK_AUDIT_PATH, {"schema": 1, "items": []})
        if not isinstance(data, dict):
            data = {"schema": 1, "items": []}
        items = data.get("items", [])
        if not isinstance(items, list):
            items = []
        origin = sanitize_url_for_log(url)
        action_s = str(action or "network")
        status_s = str(status or "")
        detail_s = str(detail or "")[:88]
        # v2026.06.20.73: de-duplicate repeated network audit messages so
        # unstable connections do not fill local diagnostics or slow down IO.
        dedupe_key = (action_s, status_s, origin, detail_s)
        now = time.time()
        last_seen = float(_AUDIT_DEDUP.get(dedupe_key, 0) or 0)
        if now - last_seen < 24:
            return
        _AUDIT_DEDUP[dedupe_key] = now
        if len(_AUDIT_DEDUP) > 96:
            for old_key in list(_AUDIT_DEDUP.keys())[:32]:
                _AUDIT_DEDUP.pop(old_key, None)
        items.append({
            "time": datetime.now().isoformat(timespec="seconds"),
            "action": action_s,
            "status": status_s,
            "origin": origin,
            "detail": detail_s,
        })
        data["schema"] = 1
        data["items"] = items[-NETWORK_AUDIT_LIMIT:]
        save_json(NETWORK_AUDIT_PATH, data)
    except Exception:
        # Network audit must never break normal app behavior.
        pass



def normalize_api_path(path: str) -> str:
    """Return a safe relative API path.

    Future server mode must not let a saved/synced path become a full external
    URL, query string carrier, or fragment carrier. The base URL policy decides
    the trusted origin; paths are kept relative and secret-free.
    """
    raw = str(path or "").strip()
    if not raw:
        return "/"
    parsed = urllib.parse.urlparse(raw)
    if parsed.scheme or parsed.netloc:
        raise ValueError("absolute URL paths are not allowed")
    clean = "/" + parsed.path.lstrip("/")
    clean = clean.replace("//", "/")
    if ".." in clean.split("/"):
        raise ValueError("parent path segments are not allowed")
    return clean or "/"


def sanitize_error_detail(detail: str, secrets=None) -> str:
    text = str(detail or "")[:240]
    for secret in secrets or []:
        secret = str(secret or "")
        if secret and secret in text:
            text = text.replace(secret, "***")
    # Remove common query-token patterns even if the exact token value is not known.
    for marker in ("token=", "api_token=", "access_token=", "password=", "secret="):
        lower = text.lower()
        pos = lower.find(marker)
        if pos >= 0:
            end = text.find(" ", pos)
            if end < 0:
                end = len(text)
            text = text[:pos + len(marker)] + "***" + text[end:]
    return text[:160]

def build_network_config(settings: dict) -> NetworkConfig:
    cfg = (settings or {}).get("server_sync", {})
    if not isinstance(cfg, dict):
        cfg = {}
    try:
        timeout = float(cfg.get("timeout", 4.5))
    except Exception:
        timeout = 4.5
    timeout = max(2.0, min(20.0, timeout))
    try:
        retry_count = int(float(cfg.get("retry_count", 2)))
    except Exception:
        retry_count = 2
    retry_count = max(0, min(4, retry_count))
    return NetworkConfig(
        enabled=bool(cfg.get("enabled", False)),
        offline_mode=bool(cfg.get("offline_mode", True)),
        base_url=str(cfg.get("base_url", "") or ""),
        workspace_id=str(cfg.get("workspace_id", "") or ""),
        api_token=str(cfg.get("api_token", "") or ""),
        timeout=timeout,
        allow_insecure_localhost=bool(cfg.get("allow_insecure_localhost", True)),
        retry_count=retry_count,
        retry_backoff_base=max(0.10, min(0.36, float(cfg.get("retry_backoff_base", 0.16) or 0.16))),
        health_cache_ttl_ok=max(20.0, min(120.0, float(cfg.get("health_cache_ttl_ok", 60.0) or 60.0))),
        health_cache_ttl_fail=max(8.0, min(60.0, float(cfg.get("health_cache_ttl_fail", 18.0) or 18.0))),
        managed_by_system=bool(cfg.get("managed_by_system", True)),
        user_configurable=bool(cfg.get("user_configurable", False)),
        auto_connect=bool(cfg.get("auto_connect", False)),
        sync_events_enabled=bool(cfg.get("sync_events_enabled", False)),
        deployment_mode=str(cfg.get("deployment_mode", "local") or "local"),
    )



def network_readiness_report(settings: dict, queue_summary: dict | None = None, write_report: bool = True) -> dict:
    """Build an offline readiness report for future server mode.

    This does not connect to the internet.  It only validates local deployment
    settings, URL policy, queue size, and secret-handling assumptions.
    """
    cfg = build_network_config(settings or {})
    queue_summary = queue_summary or {"total": 0, "pending": 0, "failed": 0, "done": 0}
    base = cfg.normalized_base_url
    ok_policy = False
    policy_key = "server_sync_status_disabled"
    policy_detail = ""
    if cfg.enabled and not cfg.offline_mode:
        ok_policy, policy_key, policy_detail = validate_base_url_policy(cfg)
    elif not base:
        policy_key = "server_sync_status_disabled" if not cfg.enabled else "server_sync_status_missing_url"
    else:
        policy_key = "server_sync_status_offline"

    managed = bool(cfg.managed_by_system and not cfg.user_configurable)
    auto_safe = bool((not cfg.auto_connect) or (cfg.auto_connect and cfg.can_connect and ok_policy and managed))
    token_safe = True  # Token is intentionally not returned in this report.
    local_only = bool(not cfg.enabled or cfg.offline_mode or not base)
    queue_ok = int(queue_summary.get("total", 0) or 0) <= 1000 and int(queue_summary.get("failed", 0) or 0) <= 250
    endpoint_ready = bool(cfg.normalized_base_url and cfg.workspace_id and cfg.api_token)
    cutover_gates = {
        "endpoint_ready": endpoint_ready,
        "https_or_localhost_only": bool(ok_policy or local_only),
        "system_managed": managed,
        "queue_bounded": queue_ok,
        "manual_user_config_disabled": not cfg.user_configurable,
        "auto_connect_safe": auto_safe,
    }

    status = "local_ready"
    if cfg.enabled and not cfg.offline_mode:
        status = "ready" if ok_policy and managed and auto_safe and queue_ok else "blocked"
    report = {
        "time": datetime.now().isoformat(timespec="seconds"),
        "status": status,
        "local_only": local_only,
        "managed_by_system": managed,
        "user_configurable": bool(cfg.user_configurable),
        "auto_connect": bool(cfg.auto_connect),
        "sync_events_enabled": bool(cfg.sync_events_enabled),
        "deployment_mode": cfg.deployment_mode,
        "origin": sanitize_url_for_log(base),
        "workspace_configured": bool(cfg.workspace_id),
        "token_present": bool(cfg.api_token),
        "token_displayed": False,
        "token_safe": token_safe,
        "policy": {
            "ok": bool(ok_policy) if cfg.enabled and not cfg.offline_mode else local_only,
            "status_key": policy_key,
            "detail": policy_detail,
            "https_or_localhost_only": bool(ok_policy or local_only),
        },
        "queue": {
            "total": int(queue_summary.get("total", 0) or 0),
            "pending": int(queue_summary.get("pending", 0) or 0),
            "failed": int(queue_summary.get("failed", 0) or 0),
            "done": int(queue_summary.get("done", 0) or 0),
            "ok": queue_ok,
        },
        "cutover": {
            "ready": bool(all(cutover_gates.values())) if cfg.enabled and not cfg.offline_mode else False,
            "will_connect_now": False,
            "gates": cutover_gates,
            "notes": [
                "dry_run_only",
                "no_user_manual_server_form",
                "token_hidden",
                "connect_only_after_system_config",
            ],
        },
        "checks": {
            "offline_first_default": local_only or cfg.enabled,
            "system_managed": managed,
            "no_user_manual_server_form": not cfg.user_configurable,
            "auto_connect_guarded": auto_safe,
            "token_not_in_report": token_safe,
            "url_policy_ok": bool(ok_policy or local_only),
            "queue_bounded": queue_ok,
        },
    }
    if write_report:
        try:
            NETWORK_PREFLIGHT_PATH.parent.mkdir(parents=True, exist_ok=True)
            save_json(NETWORK_PREFLIGHT_PATH, report)
        except Exception:
            pass
    return report



def server_cutover_contract(settings: dict) -> dict:
    """Return the expected backend contract without connecting.

    v2026.06.20.80 prepares server rollout while keeping the desktop app
    local-first.  The contract is used by deployment docs and guards so the
    future backend knows which endpoints and scope rules are required before
    administrators enable auto_connect.
    """
    cfg = build_network_config(settings or {})
    return {
        "mode": "dry_run",
        "will_connect_now": False,
        "token_displayed": False,
        "origin": sanitize_url_for_log(cfg.normalized_base_url),
        "required_endpoints": [
            "GET /health",
            "GET /api/health",
            "POST /api/bootstrap",
            "POST /api/sync",
            "POST /api/sync/batch",
            "POST /api/contacts/register",
            "POST /api/contacts/resolve",
        ],
        "scope_isolation_required": True,
        "supported_scopes": ["personal", "enterprise"],
        "required_headers": ["Authorization: Bearer <deployment-token>", "X-Workspace-ID"],
        "client_guards": [
            "offline_first_default",
            "system_managed_config_only",
            "token_hidden_from_ui_and_logs",
            "scope_required_on_sync_payloads",
            "cutover_dry_run_before_auto_connect",
        ],
    }


def network_deployment_plan(settings: dict, queue_integrity: dict | None = None, write_report: bool = True) -> dict:
    """Create a local-only future deployment plan.

    This is intentionally a dry-run artifact. It does not connect to any server,
    expose tokens, or allow normal users to edit the endpoint. It simply tells
    us whether the local app is prepared for a system-managed backend rollout.
    """
    readiness = network_readiness_report(settings or {}, write_report=False)
    queue_integrity = queue_integrity or {}
    gates = dict(readiness.get("cutover", {}).get("gates", {}))
    gates["queue_integrity_ok"] = bool(queue_integrity.get("ok", True))
    ready = bool(all(gates.values())) if readiness.get("status") == "ready" else False
    contract = server_cutover_contract(settings or {})
    plan = {
        "time": datetime.now().isoformat(timespec="seconds"),
        "mode": "dry_run",
        "will_connect_now": False,
        "ready_for_system_cutover": ready,
        "status": "ready" if ready else "local_ready" if readiness.get("local_only") else "blocked",
        "deployment_mode": readiness.get("deployment_mode", "local"),
        "origin": readiness.get("origin", ""),
        "workspace_configured": readiness.get("workspace_configured", False),
        "token_displayed": False,
        "server_contract": contract,
        "gates": gates,
        "queue": {
            "total": int(queue_integrity.get("total", readiness.get("queue", {}).get("total", 0)) or 0),
            "pending": int(queue_integrity.get("pending", readiness.get("queue", {}).get("pending", 0)) or 0),
            "failed": int(queue_integrity.get("failed", readiness.get("queue", {}).get("failed", 0)) or 0),
            "duplicated_pending": int(queue_integrity.get("duplicated_pending", 0) or 0),
            "oversized": int(queue_integrity.get("oversized", 0) or 0),
            "secret_hits": int(queue_integrity.get("secret_hits", 0) or 0),
        },
        "steps": [
            "Keep local offline mode until system config is deployed.",
            "Apply system-managed HTTPS server config.",
            "Run queue repair and dry-run plan.",
            "Enable server sync only after all gates are true.",
        ],
    }
    if write_report:
        try:
            path = DATA_DIR / "network_deployment_plan_latest.json"
            path.parent.mkdir(parents=True, exist_ok=True)
            save_json(path, plan)
        except Exception:
            pass
    return plan


class NetworkClient:
    def __init__(self, config: NetworkConfig):
        self.config = config

    def _safe_error_detail(self, exc) -> str:
        return sanitize_error_detail(str(getattr(exc, "reason", exc)), secrets=[getattr(self.config, "api_token", "")])

    def _request_with_retry(self, req):
        last_exc = None
        attempts = max(1, int(getattr(self.config, "retry_count", 2) or 0) + 1)
        backoff_base = max(0.10, min(0.36, float(getattr(self.config, "retry_backoff_base", 0.16) or 0.16)))
        for attempt in range(attempts):
            try:
                return urllib.request.urlopen(req, timeout=self.config.timeout)
            except urllib.error.HTTPError:
                # HTTP responses are real server responses, so retrying can
                # duplicate actions. Let the caller handle them directly.
                raise
            except Exception as exc:
                last_exc = exc
                if attempt >= attempts - 1:
                    break
                # v2026.06.20.62: use bounded exponential backoff so future
                # online mode stays responsive but does not hammer unstable
                # networks or wake security tooling with bursty retries.
                time.sleep(min(0.9, backoff_base * (2 ** attempt) + 0.03 * (attempt + 1)))
        raise last_exc

    def _headers(self):
        headers = {"Accept": "application/json", "Content-Type": "application/json"}
        if self.config.api_token:
            headers["Authorization"] = f"Bearer {self.config.api_token}"
        if self.config.workspace_id:
            headers["X-Workspace-ID"] = self.config.workspace_id
        return headers

    def health_check(self):
        """Return (ok: bool, status_code: str, detail: str).

        The endpoint order supports common backend layouts:
        - /health
        - /api/health
        - base URL fallback
        """
        if not self.config.enabled:
            return False, "server_sync_status_disabled", ""
        if self.config.offline_mode:
            return False, "server_sync_status_offline", ""
        base = self.config.normalized_base_url
        ok_policy, policy_key, policy_detail = validate_base_url_policy(self.config)
        if not ok_policy:
            audit_network_event("health_check", policy_key, base, policy_detail)
            return False, policy_key, policy_detail

        # v2026.06.20.17: cache short health-check results.  Settings pages and
        # system bootstrap should not create many simultaneous network attempts
        # when a user clicks quickly or when a server is temporarily offline.
        cache_key = (base, str(getattr(self.config, "workspace_id", "")))
        now = time.monotonic()
        cached = _HEALTH_CACHE.get(cache_key)
        if isinstance(cached, dict):
            ttl = float(getattr(self.config, "health_cache_ttl_ok", 45.0)) if cached.get("ok") else float(getattr(self.config, "health_cache_ttl_fail", 12.0))
            if now - float(cached.get("at", 0.0)) <= ttl:
                return bool(cached.get("ok")), str(cached.get("status_key", "")), str(cached.get("detail", ""))

        paths = ("/health", "/api/health", "")
        last_error = ""
        audit_network_event("health_check_start", "started", base)
        for path in paths:
            url = base + path
            try:
                req = urllib.request.Request(url, headers=self._headers(), method="GET")
                with self._request_with_retry(req) as resp:
                    status = getattr(resp, "status", 200)
                    if 200 <= int(status) < 400:
                        detail = f"HTTP {status}"
                        _HEALTH_CACHE[cache_key] = {"at": time.monotonic(), "ok": True, "status_key": "server_sync_status_ready", "detail": detail}
                        audit_network_event("health_check", "ready", base, detail)
                        return True, "server_sync_status_ready", detail
                    last_error = f"HTTP {status}"
            except urllib.error.HTTPError as exc:
                last_error = f"HTTP {exc.code}"
            except urllib.error.URLError as exc:
                last_error = self._safe_error_detail(exc)
            except socket.timeout:
                last_error = "timeout"
            except Exception as exc:
                last_error = self._safe_error_detail(exc)
        detail = last_error[:160]
        _HEALTH_CACHE[cache_key] = {"at": time.monotonic(), "ok": False, "status_key": "server_sync_status_failed", "detail": detail}
        audit_network_event("health_check", "failed", base, detail)
        return False, "server_sync_status_failed", detail

    def post_json(self, path: str, payload: dict):
        if not self.config.can_connect:
            return False, "server_sync_status_offline", None
        base = self.config.normalized_base_url
        ok_policy, policy_key, policy_detail = validate_base_url_policy(self.config)
        if not ok_policy:
            audit_network_event("post_json", policy_key, base, policy_detail)
            return False, policy_key, None
        try:
            path = normalize_api_path(path)
        except Exception as exc:
            detail = sanitize_error_detail(str(exc), secrets=[getattr(self.config, "api_token", "")])
            audit_network_event("post_json", "invalid_path", base, detail)
            return False, "server_sync_status_failed", detail
        url = base + path
        body = json.dumps(payload or {}, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
        try:
            req = urllib.request.Request(url, data=body, headers=self._headers(), method="POST")
            with self._request_with_retry(req) as resp:
                raw = resp.read() or b"{}"
                try:
                    data = json.loads(raw.decode("utf-8"))
                except Exception:
                    data = {"raw": raw.decode("utf-8", errors="ignore")}
                audit_network_event("post_json", "ready", base, f"HTTP {getattr(resp, 'status', 200)}")
                return True, "server_sync_status_ready", data
        except Exception as exc:
            detail = self._safe_error_detail(exc)
            audit_network_event("post_json", "failed", base, detail)
            return False, "server_sync_status_failed", detail
