"""
Shared configuration, constants, settings management, and utility helpers.

This is the lowest-level backend module — no internal backend imports.
"""

import json
import hashlib
import hmac
import io
import os
import re
import secrets
import shutil
import time
from contextlib import contextmanager
from datetime import datetime, timedelta
from pathlib import Path
from urllib.parse import urljoin

import requests
from starlette.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Request

# ── Optional dependencies ──────────────────────────────────────────────────────
try:
    import pypdf
    PDF_SUPPORT = True
except ImportError:
    PDF_SUPPORT = False

try:
    from bs4 import BeautifulSoup
    BS4_SUPPORT = True
except ImportError:
    BS4_SUPPORT = False

try:
    import win32com.client
    OUTLOOK_SUPPORT = True
except ImportError:
    OUTLOOK_SUPPORT = False

try:
    from slack_bolt import App as SlackBoltApp
    from slack_bolt.adapter.socket_mode import SocketModeHandler as SlackSocketHandler
    SLACK_BOLT_SUPPORT = True
except ImportError:
    SLACK_BOLT_SUPPORT = False

try:
    from google.oauth2.credentials import Credentials as GoogleCredentials
    from google_auth_oauthlib.flow import Flow as GoogleOAuthFlow
    from google.auth.transport.requests import Request as GoogleAuthRequest
    from googleapiclient.discovery import build as google_build
    GOOGLE_API_SUPPORT = True
except ImportError:
    GOOGLE_API_SUPPORT = False

try:
    import playwright.sync_api  # noqa: F401
    PLAYWRIGHT_SUPPORT = True
except ImportError:
    PLAYWRIGHT_SUPPORT = False


def get_playwright_status() -> dict[str, object]:
    status = {
        "available": False,
        "package_installed": False,
        "chromium_installed": False,
        "chromium_path": "",
        "detail": "Playwright not found. Run: pip install playwright && python -m playwright install chromium",
    }
    try:
        import importlib
        import importlib.util

        importlib.invalidate_caches()
        if importlib.util.find_spec("playwright.sync_api") is None:
            return status
        status["package_installed"] = True
    except Exception as exc:
        status["detail"] = f"Playwright detection failed: {exc}"
        return status

    try:
        from playwright.sync_api import sync_playwright
    except Exception as exc:
        status["detail"] = f"Playwright import failed: {exc}"
        return status

    try:
        with sync_playwright() as playwright:
            chromium_path = str(playwright.chromium.executable_path or "").strip()
        status["chromium_path"] = chromium_path
        status["chromium_installed"] = bool(chromium_path) and os.path.isfile(chromium_path)
        if status["chromium_installed"]:
            status["available"] = True
            status["detail"] = "Playwright and Chromium are installed."
        else:
            status["detail"] = "Playwright installed, but Chromium is missing. Run: python -m playwright install chromium"
    except Exception as exc:
        message = str(exc).strip()
        if "Executable doesn't exist" in message or "playwright install" in message.lower():
            status["detail"] = "Playwright installed, but Chromium is missing. Run: python -m playwright install chromium"
        else:
            status["detail"] = f"Playwright detected, but Chromium check failed: {message or exc.__class__.__name__}"
    return status

try:
    import mcp  # noqa: F401
    MCP_SUPPORT = True
except ImportError:
    MCP_SUPPORT = False

try:
    import telegram  # noqa: F401
    TELEGRAM_SUPPORT = True
except ImportError:
    TELEGRAM_SUPPORT = False

WHATSAPP_SUPPORT = True  # Bridge-based — always available via Node.js sidecar

# ── Force Google API calls to use IPv4 ──────────────────────────────────────
import socket as _socket
_orig_getaddrinfo = _socket.getaddrinfo

def _ipv4_getaddrinfo(host, port, family=_socket.AF_UNSPEC, *args, **kwargs):
    if family == _socket.AF_UNSPEC and isinstance(host, str) and (
        "googleapis.com" in host or "accounts.google.com" in host
    ):
        family = _socket.AF_INET
    return _orig_getaddrinfo(host, port, family, *args, **kwargs)

_socket.getaddrinfo = _ipv4_getaddrinfo

# ── Path constants ─────────────────────────────────────────────────────────────
OUTPUT_DIR = Path("output")
OUTPUT_DIR.mkdir(exist_ok=True)
TOOLS_DIR = Path("tools")
TOOLS_DIR.mkdir(exist_ok=True)
MEMORY_DIR = Path("memory")
MEMORY_DIR.mkdir(exist_ok=True)
MEMORY_INDEX_FILE = MEMORY_DIR / "_index.json"
SOUL_FILE = Path("soul.md")
IDENTITY_FILE = Path("identity.md")
USER_FILE = Path("user.md")
SETTINGS_FILE = Path("settings.json")
VERSION_FILE = Path("VERSION")
SCHEDULES_FILE = Path("schedules.json")
GOOGLE_TOKEN_FILE = Path("google_token.json")
MICROSOFT_TOKEN_FILE = Path("microsoft_token.json")

# ── Microsoft Graph OAuth constants ────────────────────────────────────────────
MICROSOFT_AUTH_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
MICROSOFT_TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
MICROSOFT_GRAPH_URL = "https://graph.microsoft.com/v1.0"
MICROSOFT_SCOPES = "https://graph.microsoft.com/Files.ReadWrite.All https://graph.microsoft.com/User.Read offline_access"


def _load_schedules() -> list[dict]:
    if SCHEDULES_FILE.exists():
        try:
            return json.loads(SCHEDULES_FILE.read_text(encoding="utf-8"))
        except Exception:
            pass
    return []


def _save_schedules(schedules: list[dict]):
    SCHEDULES_FILE.write_text(json.dumps(schedules, indent=2), encoding="utf-8")

WORKSPACE_ROOT = Path(os.environ.get("AGENT_WORKSPACE", Path(__file__).parent.parent)).resolve()

# ── URL & model provider constants ─────────────────────────────────────────────
OLLAMA_URL    = "http://localhost:11434/api"
NVIDIA_URL    = "https://integrate.api.nvidia.com/v1/chat/completions"
OPENAI_URL    = "https://api.openai.com/v1/chat/completions"
GEMINI_URL    = "https://generativelanguage.googleapis.com/v1beta/models"

NVIDIA_PREFIX  = "nvidia/"
OPENAI_PREFIX  = "openai/"
GEMINI_PREFIX  = "gemini/"
LLAMACPP_URL    = "http://localhost:8080"
LLAMACPP_PREFIX = "llamacpp/"

GOOGLE_SCOPES = [
    "https://www.googleapis.com/auth/gmail.readonly",
    "https://www.googleapis.com/auth/gmail.send",
    "https://www.googleapis.com/auth/gmail.compose",
    "https://www.googleapis.com/auth/gmail.modify",
    "https://www.googleapis.com/auth/calendar",
    "https://www.googleapis.com/auth/drive",
]

UPDATE_BASE_URL = "https://www.hetphoenixproject.nl/AgentOrchestrator/"
UPDATE_MANIFEST_NAME = "manifest.json"
DEFAULT_APP_VERSION = "0.0.1"

_UPDATE_PROTECTED_EXACT = {
    "settings.json", "google_token.json", "microsoft_token.json", "schedules.json",
    "soul.md", "identity.md", "user.md", ".auth_token",
}
_UPDATE_PROTECTED_PREFIXES = (
    "memory/", "output/", "downloads/", "attachments/",
)

# ── Settings management ────────────────────────────────────────────────────────

_SECRET_KEYS = {
    "google_client_secret", "slack_bot_token", "slack_app_token", "slack_webhook_url",
    "telegram_bot_token",
    "brave_api_key", "ollama_cloud_api_key", "nvidia_api_key",
    "openai_api_key", "gemini_api_key",
    "microsoft_client_secret",
    "whatsapp_access_token", "whatsapp_phone_number_id", "whatsapp_verify_token",
}

_KEYRING_SERVICE = "AgentOrchestrator"
_KEYRING_PLACEHOLDER = "__KEYRING__"

try:
    import keyring as _keyring
    _KEYRING_AVAILABLE = True
except ImportError:
    _KEYRING_AVAILABLE = False
DEFAULT_SETTINGS: dict = {
    "setup_complete": False,
    "copilot_enabled": False,
    "copilot_morning_brief_enabled": False,
    "copilot_morning_brief_time": "08:30",
    "copilot_followup_enabled": False,
    "copilot_followup_time": "11:30",
    "copilot_briefing_time": "08:30",
    "copilot_autonomy_level": "suggest",
    "copilot_morning_brief_instructions": "",
    "copilot_followup_instructions": "",
    "copilot_instructions": "",
    "outlook_enabled": False,
    "google_enabled": False,
    "google_client_id": "",
    "google_client_secret": "",
    "microsoft_enabled": False,
    "microsoft_client_id": "",
    "microsoft_client_secret": "",
    "onedrive_mode": "local",
    "onedrive_local_path": "",
    "slack_enabled": False,
    "slack_bot_token": "",
    "slack_app_token": "",
    "slack_webhook_url": "",
    "telegram_enabled": False,
    "telegram_bot_token": "",
    "whatsapp_enabled": False,
    "main_model": "glm-5.1:cloud",
    "fast_model": "gpt-oss:20b-cloud",
    "fallback_model": "qwen3.5:9b",
    "brave_api_key": "",
    "nvidia_api_key": "",
    "openai_api_key": "",
    "gemini_api_key": "",
    "llamacpp_url": "http://localhost:8080",
    "llamacpp_model_path": "",
    "llamacpp_extra_args": "",
    "llamacpp_server_exe": "",
    "embed_model": "",
    "policy_budget_mode": "budget",
    "policy_max_steps_per_run": 8,
    "policy_max_tool_calls_per_run": 20,
    "policy_max_validation_retries_per_step": 1,
    "policy_max_total_tokens_per_run": 120000,
    "policy_max_runtime_seconds": 900,
    "policy_blocked_tools": [],
    "policy_require_approval_tools": ["run_shell", "http_request", "create_tool", "update_tool", "run_python", "fetch_url", "browser_navigate", "browser_click", "browser_type", "outlook_send_email", "gmail_send_email"],
    "custom_tool_runtime_failure_threshold": 3,
    "custom_tool_quality_issue_threshold": 3,
    "custom_tool_auto_disable_on_failure": True,
    "custom_tool_require_hitl_for_updates": True,
    "browser_headless": True,
    "show_thinking": True,
    "mcp_servers": {},
    "tool_enabled_map": {},
    "webhooks": [],
    "context_sources": [],
    "outlook_extra_folders": [],
    "outlook_folder_profiles": {},
    "google_extra_folders": [],
    "google_folder_profiles": {},
}

_BUILTIN_TOOL_NAMES = {
    "web_search", "fetch_url", "extract_source", "run_python", "write_file", "read_file",
    "list_files", "run_shell", "http_request", "read_workspace_file",
    "search_workspace", "write_web_files", "preview_web_app", "package_web_app", "create_tool", "update_tool", "schedule_goal",
    "describe_image",
    "outlook_get_inbox", "outlook_send_email", "outlook_read_calendar",
    "outlook_update_email", "outlook_delete_email",
    "outlook_create_calendar_event", "outlook_update_calendar_event", "outlook_delete_calendar_event",
    "outlook_search_emails",
    "gmail_get_inbox", "gmail_send_email", "gmail_update_email", "gmail_delete_email", "gmail_search_emails",
    "google_read_calendar", "google_create_calendar_event",
    "google_update_calendar_event", "google_delete_calendar_event",
    "slack_post_message",
    "whatsapp_send_message",
    "fetch_webhook",
    "google_drive_list", "google_drive_search",
    "google_drive_read", "google_drive_upload",
    "onedrive_list", "onedrive_search",
    "onedrive_read", "onedrive_upload",
    "browser_navigate", "browser_click", "browser_type",
    "browser_screenshot", "browser_get_text", "browser_eval",
    "mcp_call", "mcp_list_tools",
    "find_free_slots",
    "gmail_manage_labels", "outlook_move_email",
    "gmail_bulk_action", "outlook_bulk_action",
    "gmail_get_attachments", "outlook_get_attachments",
}

_PROMOTED_CUSTOM_BUILTIN_NAMES = {
    "analyze_attachments", "analyze_non_pdf_attachments", "clean_entity", "contact_lookup", "detect_provider",
    "email_parser", "email_triage",
    "extract_email_attachments", "parse_date", "process_outlook_attachments",
    "resolve_ordinal", "summarize_personal_items", "update_user_file", "youtube_search",
}
_BUILTIN_TOOL_NAMES.update(_PROMOTED_CUSTOM_BUILTIN_NAMES)


def _normalize_tool_name(name: str) -> str:
    raw = str(name or "").strip()
    if raw == "create_skill":
        return "create_tool"
    if raw == "update_skill":
        return "update_tool"
    if raw == "analyze_non_pdf_attachments":
        return "analyze_attachments"
    return raw


def _normalize_tool_enabled_map(tool_map: dict | None) -> dict[str, bool]:
    normalized: dict[str, bool] = {}
    for raw_name, enabled in dict(tool_map or {}).items():
        name = _normalize_tool_name(raw_name)
        if not name:
            continue
        normalized[name] = bool(enabled)
    return normalized


# ── Model-aware context sizing ────────────────────────────────────────────────

def _is_cloud_model(model_name: str) -> bool:
    """Return True if the model name indicates an Ollama cloud / remote model."""
    name = (model_name or "").strip().lower()
    return ":cloud" in name or name.startswith("gpt-oss:") or name.startswith("minimax")


def _model_ctx(base: int, settings: dict | None = None) -> int:
    """Return *base* for local models; scale up for cloud models.

    Cloud models (`:cloud` suffix, gpt-oss, minimax) typically support 128K+
    context windows, so we multiply the base context by 4 (capped at 131072).
    The Ollama call layer already has a fallback cascade, so overshooting is safe.
    """
    if settings is None:
        settings = _load_settings()
    models = [
        (settings.get("main_model") or ""),
        (settings.get("fast_model") or ""),
        (settings.get("fallback_model") or ""),
    ]
    if any(_is_cloud_model(m) for m in models):
        return min(base * 4, 131072)
    return base


_settings_cache: dict | None = None
_settings_cache_ts: float = 0.0
_SETTINGS_CACHE_TTL = 2.0


def _clean_context_source_text(value, limit: int = 240) -> str:
    return str(value or "").strip()[:limit]


def _context_source_key(entry: dict) -> tuple[str, str, str]:
    return (
        str(entry.get("kind") or "").strip().lower(),
        str(entry.get("provider") or "").strip().lower(),
        str(entry.get("path") or "").strip().lower(),
    )


def _normalize_context_source_entry(entry: dict | None) -> dict | None:
    if not isinstance(entry, dict):
        return None

    kind = str(entry.get("kind") or entry.get("type") or "").strip().lower()
    provider = str(entry.get("provider") or "").strip().lower()
    path = str(
        entry.get("path")
        or entry.get("folder")
        or entry.get("label")
        or entry.get("directory")
        or ""
    ).strip()
    description = _clean_context_source_text(
        entry.get("description")
        or entry.get("notes")
        or entry.get("label_description")
        or ""
    )
    enabled = _to_bool(entry.get("enabled", True))

    if not kind:
        if provider in {"outlook", "google"} and path:
            kind = "email_folder"
        elif path:
            kind = "local_directory"

    if not kind or not path:
        return None

    if kind == "email_folder" and provider not in {"outlook", "google"}:
        return None

    normalized = {
        "kind": kind,
        "provider": provider,
        "path": path,
        "description": description,
        "enabled": enabled,
    }
    return normalized


def _legacy_context_sources_from_settings(settings: dict | None) -> list[dict]:
    source = settings or {}
    merged: list[dict] = []
    for provider, extra_key, profiles_key in (
        ("outlook", "outlook_extra_folders", "outlook_folder_profiles"),
        ("google", "google_extra_folders", "google_folder_profiles"),
    ):
        extra_raw = source.get(extra_key) if isinstance(source.get(extra_key), list) else []
        profiles_raw = source.get(profiles_key) if isinstance(source.get(profiles_key), dict) else {}
        all_paths: set[str] = set()
        for item in extra_raw:
            path = str(item or "").strip()
            if path:
                all_paths.add(path)
        for key in profiles_raw.keys():
            path = str(key or "").strip()
            if path:
                all_paths.add(path)
        for path in sorted(all_paths, key=lambda value: value.lower()):
            merged.append({
                "kind": "email_folder",
                "provider": provider,
                "path": path,
                "description": _clean_context_source_text(profiles_raw.get(path) or ""),
                "enabled": True,
            })
    return merged


def _merge_context_sources(*groups: list[dict]) -> list[dict]:
    merged: dict[tuple[str, str, str], dict] = {}
    for group in groups:
        for raw_entry in list(group or []):
            entry = _normalize_context_source_entry(raw_entry)
            if not entry:
                continue
            key = _context_source_key(entry)
            existing = merged.get(key)
            if existing is None:
                merged[key] = entry
                continue
            existing["enabled"] = bool(existing.get("enabled", False) or entry.get("enabled", False))
            if not existing.get("description") and entry.get("description"):
                existing["description"] = entry["description"]
    return [merged[key] for key in sorted(merged.keys())]


def _derive_legacy_folder_settings(context_sources: list[dict] | None) -> dict:
    legacy = {
        "outlook_extra_folders": [],
        "outlook_folder_profiles": {},
        "google_extra_folders": [],
        "google_folder_profiles": {},
    }
    for raw_entry in list(context_sources or []):
        entry = _normalize_context_source_entry(raw_entry)
        if not entry or entry.get("kind") != "email_folder" or not entry.get("enabled", True):
            continue
        provider = str(entry.get("provider") or "").strip().lower()
        path = str(entry.get("path") or "").strip()
        if provider not in {"outlook", "google"} or not path:
            continue
        extra_key = f"{provider}_extra_folders"
        profiles_key = f"{provider}_folder_profiles"
        legacy[extra_key].append(path)
        legacy[profiles_key][path] = _clean_context_source_text(entry.get("description") or "")

    for provider in ("outlook", "google"):
        extra_key = f"{provider}_extra_folders"
        unique_sorted = sorted({str(path).strip() for path in legacy[extra_key] if str(path).strip()}, key=lambda value: value.lower())
        legacy[extra_key] = unique_sorted
    return legacy


def _mcp_windows_command_candidates(command: str) -> list[str]:
    raw = str(command or "").strip()
    if not raw:
        return []

    lowered = raw.lower()
    if lowered == "npx":
        return [raw, "npx.cmd", "npx.exe"]
    if lowered == "npm":
        return [raw, "npm.cmd", "npm.exe"]
    if lowered == "node":
        return [raw, "node.exe"]
    if lowered == "uvx":
        return [raw, "uvx.exe", "uvx.cmd"]
    if lowered == "python":
        return [sys.executable, raw, "python.exe", "py"]
    return [raw]


def _resolve_mcp_command(command: str) -> str:
    raw = str(command or "").strip()
    if not raw:
        return ""

    path = Path(raw)
    if path.is_file():
        return str(path)

    candidates = [raw]
    if os.name == "nt":
        candidates = _mcp_windows_command_candidates(raw)

    seen: set[str] = set()
    for candidate in candidates:
        item = str(candidate or "").strip()
        if not item or item in seen:
            continue
        seen.add(item)
        resolved = shutil.which(item)
        if resolved:
            return resolved

    return raw


def _mcp_command_available(command: str) -> bool:
    resolved = _resolve_mcp_command(command)
    if not resolved:
        return False
    if Path(resolved).is_file():
        return True
    return bool(shutil.which(resolved))


def _normalize_mcp_server_config(entry: dict | None) -> dict | None:
    if not isinstance(entry, dict):
        return None

    command = _resolve_mcp_command(str(entry.get("command") or "").strip())
    if not command:
        return None

    args_raw = entry.get("args") if isinstance(entry.get("args"), list) else []
    args = [str(arg or "") for arg in args_raw]

    env_raw = entry.get("env") if isinstance(entry.get("env"), dict) else {}
    env: dict[str, str] = {}
    for raw_key, raw_value in env_raw.items():
        key = str(raw_key or "").strip()
        if not key:
            continue
        env[key] = str(raw_value or "")

    normalized = {
        "command": command,
        "args": args,
    }
    if env:
        normalized["env"] = env
    return normalized


def _normalize_settings_payload(settings: dict | None) -> dict:
    normalized = {**DEFAULT_SETTINGS, **dict(settings or {})}
    normalized_servers: dict[str, dict] = {}
    raw_servers = normalized.get("mcp_servers") if isinstance(normalized.get("mcp_servers"), dict) else {}
    for raw_name, raw_cfg in raw_servers.items():
        name = str(raw_name or "").strip()
        cfg = _normalize_mcp_server_config(raw_cfg)
        if not name or cfg is None:
            continue
        normalized_servers[name] = cfg
    normalized["mcp_servers"] = normalized_servers
    context_sources = _merge_context_sources(
        normalized.get("context_sources") if isinstance(normalized.get("context_sources"), list) else [],
        _legacy_context_sources_from_settings(normalized),
    )
    normalized["context_sources"] = context_sources
    normalized.update(_derive_legacy_folder_settings(context_sources))
    return normalized

def _load_settings() -> dict:
    global _settings_cache, _settings_cache_ts
    now = time.time()
    if _settings_cache is not None and (now - _settings_cache_ts) < _SETTINGS_CACHE_TTL:
        return _settings_cache
    if SETTINGS_FILE.exists():
        try:
            result = _normalize_settings_payload(json.loads(SETTINGS_FILE.read_text(encoding="utf-8")))
            # Decrypt secret keys stored in keyring
            if _KEYRING_AVAILABLE:
                for key in _SECRET_KEYS:
                    if result.get(key) == _KEYRING_PLACEHOLDER:
                        try:
                            real = _keyring.get_password(_KEYRING_SERVICE, key)
                            if real:
                                result[key] = real
                        except Exception as _ke:
                            print(f"[config] keyring read failed for {key}: {_ke}")
            _settings_cache = result
            _settings_cache_ts = now
            return result
        except Exception:
            pass
    return _normalize_settings_payload(DEFAULT_SETTINGS)


def _save_settings(s: dict):
    global _settings_cache, _settings_cache_ts
    normalized = _normalize_settings_payload(s)
    # Keep full values in memory cache
    _settings_cache = normalized
    _settings_cache_ts = time.time()
    # Write to disk with secrets moved to keyring
    disk_copy = dict(normalized)
    if _KEYRING_AVAILABLE:
        for key in _SECRET_KEYS:
            val = disk_copy.get(key, "")
            if val and val != _KEYRING_PLACEHOLDER:
                _keyring.set_password(_KEYRING_SERVICE, key, val)
                disk_copy[key] = _KEYRING_PLACEHOLDER
    SETTINGS_FILE.write_text(json.dumps(disk_copy, indent=2), encoding="utf-8")


def _migrate_plaintext_secrets():
    """One-time migration: encrypt any plaintext secrets already in settings.json."""
    if not _KEYRING_AVAILABLE or not SETTINGS_FILE.exists():
        return
    try:
        raw = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
    except Exception:
        return
    needs_write = False
    for key in _SECRET_KEYS:
        val = raw.get(key, "")
        if val and val != _KEYRING_PLACEHOLDER:
            _keyring.set_password(_KEYRING_SERVICE, key, val)
            raw[key] = _KEYRING_PLACEHOLDER
            needs_write = True
    if needs_write:
        SETTINGS_FILE.write_text(json.dumps(raw, indent=2), encoding="utf-8")
        print(f"[config] Migrated {sum(1 for k in _SECRET_KEYS if raw.get(k) == _KEYRING_PLACEHOLDER)} secret(s) to keyring")


def _to_bool(value) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        return value.lower() in ("true", "1", "yes", "on")
    return bool(value)


# ── Version management ─────────────────────────────────────────────────────────
def _get_local_version() -> str:
    try:
        if VERSION_FILE.exists():
            v = VERSION_FILE.read_text(encoding="utf-8").strip()
            if v:
                return v
    except Exception:
        pass
    return DEFAULT_APP_VERSION


def _parse_version_tuple(v: str) -> tuple:
    nums = [int(x) for x in re.findall(r"\d+", str(v or ""))]
    if not nums:
        return (0,)
    return tuple(nums)


def _is_remote_newer(local_version: str, remote_version: str) -> bool:
    return _parse_version_tuple(remote_version) > _parse_version_tuple(local_version)


def _default_manifest_url() -> str:
    return urljoin(UPDATE_BASE_URL, UPDATE_MANIFEST_NAME)


class _UpdateManifestFetchError(Exception):
    def __init__(self, attempted_urls: list[str], last_error: Exception | None = None):
        self.attempted_urls = attempted_urls
        self.last_error = last_error
        msg = "Unable to fetch update manifest from any configured URL."
        if attempted_urls:
            msg += " Attempted: " + ", ".join(attempted_urls)
        if last_error is not None:
            msg += f". Last error: {last_error}"
        super().__init__(msg)


def _candidate_manifest_urls(manifest_url: str | None = None) -> list[str]:
    explicit = (manifest_url or "").strip()
    if explicit:
        return [explicit]
    settings = _load_settings()
    candidates: list[str] = []
    configured_manifest = str(settings.get("update_manifest_url") or "").strip()
    if configured_manifest:
        candidates.append(configured_manifest)
    configured_base = str(settings.get("update_base_url") or "").strip()
    if configured_base:
        candidates.append(urljoin(configured_base, UPDATE_MANIFEST_NAME))
    candidate_bases = [
        UPDATE_BASE_URL,
        "http://www.hetphoenixproject.nl/AgentOrchestrator/",
        "https://www.hetphoenixproject.nl/AgentOrchestrator/",
        "http://hetphoenixproject.nl/AgentOrchestrator/",
        "https://hetphoenixproject.nl/AgentOrchestrator/",
    ]
    for base in candidate_bases:
        candidates.append(urljoin(base, UPDATE_MANIFEST_NAME))
    seen: set[str] = set()
    out: list[str] = []
    for url in candidates:
        normalized = str(url or "").strip()
        if not normalized:
            continue
        if normalized in seen:
            continue
        seen.add(normalized)
        out.append(normalized)
    return out


def _fetch_update_manifest(manifest_url: str | None = None) -> dict:
    attempted_urls: list[str] = []
    last_error: Exception | None = None
    for src_url in _candidate_manifest_urls(manifest_url):
        attempted_urls.append(src_url)
        try:
            res = requests.get(src_url, timeout=15)
            res.raise_for_status()
            raw = json.loads(res.content.decode("utf-8-sig"))
            if not isinstance(raw, dict):
                raise ValueError("Manifest response must be a JSON object.")
            latest = str(raw.get("latest_version") or raw.get("version") or "").strip()
            if not latest:
                raise ValueError("Manifest is missing version/latest_version.")
            download_url = str(
                raw.get("download_url") or raw.get("package_url")
                or raw.get("zip_url") or raw.get("download") or ""
            ).strip()
            if not download_url:
                raise ValueError("Manifest is missing download_url.")
            if not download_url.lower().startswith(("http://", "https://")):
                download_url = urljoin(src_url, download_url)
            notes = raw.get("notes") or raw.get("changelog") or ""
            if isinstance(notes, list):
                notes = "\n".join(str(x) for x in notes)
            notes = str(notes or "").strip()
            return {
                "manifest_url": src_url, "attempted_urls": attempted_urls,
                "latest_version": latest, "download_url": download_url,
                "sha256": str(raw.get("sha256") or "").strip().lower(),
                "notes": notes, "raw": raw,
            }
        except Exception as e:
            last_error = e
            continue
    raise _UpdateManifestFetchError(attempted_urls=attempted_urls, last_error=last_error)


def _is_dns_resolution_error(exc: Exception) -> bool:
    if isinstance(exc, _UpdateManifestFetchError) and exc.last_error is not None:
        return _is_dns_resolution_error(exc.last_error)
    text = str(exc or "").lower()
    return (
        "nameresolutionerror" in text
        or "failed to resolve" in text
        or "getaddrinfo failed" in text
    )


def _is_update_protected(rel_posix: str) -> bool:
    rel = rel_posix.strip().lstrip("/").replace("\\", "/")
    lower = rel.lower()
    if lower in _UPDATE_PROTECTED_EXACT:
        return True
    return any(lower.startswith(prefix) for prefix in _UPDATE_PROTECTED_PREFIXES)


def _apply_update_from_extracted_dir(source_root: Path, target_root: Path) -> dict:
    copied = 0
    skipped = 0
    skipped_paths: list[str] = []
    for src in source_root.rglob("*"):
        rel_posix = src.relative_to(source_root).as_posix()
        if not rel_posix:
            continue
        if _is_update_protected(rel_posix):
            skipped += 1
            if len(skipped_paths) < 25:
                skipped_paths.append(rel_posix)
            continue
        dst = target_root / rel_posix
        if src.is_dir():
            dst.mkdir(parents=True, exist_ok=True)
            continue
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        copied += 1
    return {"copied_files": copied, "skipped_files": skipped, "skipped_sample": skipped_paths}


# ── Authentication ─────────────────────────────────────────────────────────────
_AUTH_TOKEN_FILE = Path(".auth_token")
_AUTH_EXEMPT_PATHS = {"/api/integrations/google/callback", "/api/integrations/microsoft/callback", "/api/auth/token", "/api/spawn-launcher"}

def _load_or_create_auth_token() -> str:
    if _AUTH_TOKEN_FILE.exists():
        token = _AUTH_TOKEN_FILE.read_text(encoding="utf-8").strip()
        if token:
            return token
    token = secrets.token_urlsafe(48)
    _AUTH_TOKEN_FILE.write_text(token, encoding="utf-8")
    return token

API_AUTH_TOKEN: str = _load_or_create_auth_token()


class _BearerAuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        path = request.url.path
        if (path in _AUTH_EXEMPT_PATHS
            or path.startswith("/frontend/")
            or path == "/"
            or not path.startswith("/api")):
            return await call_next(request)
        auth_header = request.headers.get("authorization", "")
        if not auth_header.startswith("Bearer "):
            return _json_401()
        token = auth_header[7:]
        if not hmac.compare_digest(token, API_AUTH_TOKEN):
            return _json_401()
        return await call_next(request)


def _json_401():
    return JSONResponse({"detail": "Unauthorized"}, status_code=401)


# ── Outlook COM helper ─────────────────────────────────────────────────────────
@contextmanager
def _outlook_session():
    import pythoncom
    import win32com.client
    pythoncom.CoInitialize()
    try:
        outlook = win32com.client.Dispatch("Outlook.Application")
        ns = outlook.GetNamespace("MAPI")
        yield outlook, ns
    finally:
        pythoncom.CoUninitialize()


# ── Google credential helpers ──────────────────────────────────────────────────
def _google_get_credentials():
    """Return valid Google credentials or None.  Returns None when
    the saved token is missing required scopes so callers do not
    accidentally use an under-scoped token."""
    if not GOOGLE_API_SUPPORT:
        return None
    if not GOOGLE_TOKEN_FILE.exists():
        return None
    if not _google_has_all_scopes():
        return None
    try:
        creds = GoogleCredentials.from_authorized_user_file(
            str(GOOGLE_TOKEN_FILE), GOOGLE_SCOPES
        )
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(GoogleAuthRequest())
            GOOGLE_TOKEN_FILE.write_text(creds.to_json(), encoding="utf-8")
        if creds and creds.valid:
            return creds
    except Exception as e:
        print(f"[Google] Credential refresh error: {e}")
    return None


def _google_has_all_scopes() -> bool:
    """Check whether the saved token covers all required GOOGLE_SCOPES."""
    if not GOOGLE_API_SUPPORT or not GOOGLE_TOKEN_FILE.exists():
        return True  # no token means nothing to check
    try:
        raw = json.loads(GOOGLE_TOKEN_FILE.read_text(encoding="utf-8"))
        granted = set(raw.get("scopes") or [])
        return set(GOOGLE_SCOPES).issubset(granted)
    except Exception:
        return True


def _google_has_calendar_write_scope() -> bool:
    if not GOOGLE_API_SUPPORT or not GOOGLE_TOKEN_FILE.exists():
        return False
    try:
        creds = GoogleCredentials.from_authorized_user_file(
            str(GOOGLE_TOKEN_FILE), GOOGLE_SCOPES
        )
        granted = set(creds.scopes or []) if creds else set()
        return "https://www.googleapis.com/auth/calendar" in granted
    except Exception:
        return False


def _google_build_flow(settings: dict):
    """Build a Google OAuth2 flow from settings."""
    client_id = (settings.get("google_client_id") or "").strip()
    client_secret = (settings.get("google_client_secret") or "").strip()
    if not client_id or not client_secret:
        raise ValueError("Google OAuth not configured: missing client_id / client_secret in Settings.")
    client_config = {
        "web": {
            "client_id": client_id,
            "client_secret": client_secret,
            "auth_uri": "https://accounts.google.com/o/oauth2/auth",
            "token_uri": "https://oauth2.googleapis.com/token",
        }
    }
    return GoogleOAuthFlow.from_client_config(
        client_config, scopes=GOOGLE_SCOPES,
        redirect_uri="http://localhost:8000/api/integrations/google/callback",
    )


_google_service_cache: dict[tuple[str, str], tuple] = {}

def _google_service(api: str, version: str):
    """Return a cached Google API service, rebuilding only when credentials change."""
    creds = _google_get_credentials()
    if not creds:
        return None
    cache_key = (api, version)
    token_id = creds.token
    cached = _google_service_cache.get(cache_key)
    if cached and cached[1] == token_id:
        return cached[0]
    try:
        svc = google_build(api, version, credentials=creds, cache_discovery=True)
        _google_service_cache[cache_key] = (svc, token_id)
        return svc
    except Exception as e:
        print(f"[Google] Service build error ({api}): {e}")
        return None


# ── OneDrive local folder detection ────────────────────────────────────────────
def _detect_onedrive_local_path() -> str:
    """Auto-detect the local OneDrive sync folder on Windows."""
    import platform
    if platform.system() != "Windows":
        return ""
    home = Path.home()
    # Common OneDrive folder names (Business and Personal)
    candidates = [
        home / "OneDrive",
        home / "OneDrive - Personal",
    ]
    # Also check env var
    for env_key in ("OneDrive", "OneDriveConsumer", "OneDriveCommercial"):
        val = os.environ.get(env_key, "")
        if val:
            candidates.insert(0, Path(val))
    for p in candidates:
        if p.is_dir():
            return str(p)
    return ""


# ── Microsoft Graph credential helpers ─────────────────────────────────────────
def _microsoft_get_token() -> str | None:
    """Return a valid Microsoft Graph access token, refreshing if needed."""
    if not MICROSOFT_TOKEN_FILE.exists():
        return None
    try:
        data = json.loads(MICROSOFT_TOKEN_FILE.read_text(encoding="utf-8"))
        access_token = data.get("access_token", "")
        expires_at = data.get("expires_at", 0)
        if access_token and time.time() < expires_at - 60:
            return access_token
        refresh_token = data.get("refresh_token", "")
        if not refresh_token:
            return None
        s = _load_settings()
        resp = requests.post(MICROSOFT_TOKEN_URL, data={
            "client_id": (s.get("microsoft_client_id") or "").strip(),
            "client_secret": (s.get("microsoft_client_secret") or "").strip(),
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
            "scope": MICROSOFT_SCOPES,
        }, timeout=15)
        resp.raise_for_status()
        token_data = resp.json()
        token_data["expires_at"] = time.time() + token_data.get("expires_in", 3600)
        MICROSOFT_TOKEN_FILE.write_text(json.dumps(token_data), encoding="utf-8")
        return token_data.get("access_token")
    except Exception as e:
        print(f"[Microsoft] Token refresh error: {e}")
        return None


def _microsoft_graph_headers() -> dict | None:
    """Return Authorization headers for Microsoft Graph, or None."""
    token = _microsoft_get_token()
    if not token:
        return None
    return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}


# ── Identity files ─────────────────────────────────────────────────────────────
def _ensure_identity_files():
    try:
        if not USER_FILE.exists():
            if IDENTITY_FILE.exists():
                USER_FILE.write_text(IDENTITY_FILE.read_text(encoding="utf-8"), encoding="utf-8")
            else:
                USER_FILE.write_text("# User Profile\n\n- Name: unknown\n- Hobbies: unknown\n", encoding="utf-8")
    except Exception:
        pass


_ensure_identity_files()
