"""
Sync engine for Nia Local Sync CLI.

Handles:
- Extracting data from local sources (databases, folders)
- Uploading to cloud API
- Cursor management for incremental sync
"""
import os
import logging
import random
import re
import time
from pathlib import Path
from typing import Any
import httpx

from config import get_api_base_url, get_api_key
from extractor import extract_incremental, detect_source_type, TYPE_FOLDER

logger = logging.getLogger(__name__)

SYNC_TIMEOUT = 60  # 1 minute per sync request (reduced from 2 min)
CONNECT_TIMEOUT = 10  # 10 second connection timeout
MAX_FILES_PER_BATCH = 500  # Keep below backend limit (1000)
MAX_BATCH_SIZE_BYTES = 4 * 1024 * 1024  # 4MB max payload per batch
MAX_RETRIES = 4
RETRY_BASE_DELAY = 1.5
RETRY_MAX_DELAY = 15.0
FOLDER_CURSOR_VERSION = 1

# Reusable client for connection pooling
_http_client: httpx.Client | None = None

def _normalize_folder_cursor(
    path: str,
    cursor: dict[str, Any] | None,
) -> tuple[dict[str, Any], str | None]:
    if not cursor:
        return {}, "missing"

    cursor_version = cursor.get("cursor_version")
    root_path = cursor.get("root_path")
    normalized_root = os.path.abspath(os.path.expanduser(root_path)) if root_path else None

    if cursor_version != FOLDER_CURSOR_VERSION:
        return {}, "version_mismatch"
    if not normalized_root:
        return {}, "missing_root_path"
    if normalized_root != path:
        return {}, "root_path_changed"

    return cursor, None

def get_http_client() -> httpx.Client:
    """Get or create HTTP client with connection pooling."""
    global _http_client
    if _http_client is None:
        _http_client = httpx.Client(
            timeout=httpx.Timeout(SYNC_TIMEOUT, connect=CONNECT_TIMEOUT),
            limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
        )
    return _http_client


def sync_all_sources(sources: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """
    Sync all configured sources.

    Args:
        sources: List of source configs from cloud API

    Returns:
        List of results for each source
    """
    results = []

    for source in sources:
        result = sync_source(source)
        results.append(result)

    return results


def sync_source(source: dict[str, Any]) -> dict[str, Any]:
    """
    Sync a single source.

    Args:
        source: Source config from cloud API with:
            - local_folder_id: UUID of the local folder
            - path: Local path to sync
            - detected_type: Type of source
            - cursor: Current sync cursor

    Returns:
        Result dict with status, path, and stats
    """
    local_folder_id = source.get("local_folder_id")
    path = source.get("path") or ""
    detected_type = source.get("detected_type")
    cursor = source.get("cursor", {})

    # Skip sources without a valid path (e.g., remote-only sources)
    if not path:
        logger.debug(f"Skipping source {local_folder_id}: no path configured")
        return {
            "path": None,
            "status": "skipped",
            "message": "No local path configured",
        }

    # Expand ~ and normalize path
    path = os.path.expanduser(path)
    path = os.path.abspath(path)

    # Validate path exists
    if not os.path.exists(path):
        error_message = f"Path does not exist: {path}"
        report_sync_error(local_folder_id, error_message, path)
        return {
            "path": path,
            "status": "error",
            "error": error_message,
        }

    # Auto-detect type if not specified
    if not detected_type:
        detected_type = detect_source_type(path)

    cursor_reset_reason = None
    if detected_type == TYPE_FOLDER:
        cursor, cursor_reset_reason = _normalize_folder_cursor(path, cursor)
        if cursor_reset_reason:
            logger.info(f"Resetting folder sync cursor for {path} ({cursor_reset_reason})")

    logger.info(f"Syncing {path} (type={detected_type})")

    try:
        # Extract data incrementally
        extraction_result = extract_incremental(
            path=path,
            source_type=detected_type,
            cursor=cursor,
        )

        files = extraction_result.get("files", [])
        new_cursor = dict(extraction_result.get("cursor", {}))
        stats = extraction_result.get("stats", {})

        if detected_type == TYPE_FOLDER:
            new_cursor["cursor_version"] = FOLDER_CURSOR_VERSION
            new_cursor["root_path"] = path

        if not files:
            if detected_type == TYPE_FOLDER and cursor_reset_reason:
                cursor_update = new_cursor
                upload_result = upload_sync_data(
                    local_folder_id=local_folder_id,
                    files=[],
                    cursor=cursor_update,
                    stats=stats,
                    is_final_batch=True,
                )
                if upload_result.get("status") == "ok":
                    source["cursor"] = cursor_update
                    return {
                        "path": path,
                        "status": "success",
                        "added": 0,
                        "message": "No new data (cursor updated)",
                    }
                report_sync_error(local_folder_id, upload_result.get("message", "Upload failed"), path)
                return {
                    "path": path,
                    "status": "error",
                    "error": upload_result.get("message", "Upload failed"),
                }

            logger.info(f"No new data to sync for {path}")
            return {
                "path": path,
                "status": "success",
                "added": 0,
                "message": "No new data",
            }

        # Upload to backend in batches
        upload_result = upload_sync_batches(
            local_folder_id=local_folder_id,
            files=files,
            cursor=new_cursor,
            stats=stats,
        )

        if upload_result.get("status") == "ok":
            # Update source cursor in-place so subsequent syncs use it
            source["cursor"] = new_cursor
            return {
                "path": path,
                "status": "success",
                "added": len(files),
                "chunks_indexed": upload_result.get("chunks_indexed", 0),
                "new_cursor": new_cursor,
            }
        else:
            report_sync_error(local_folder_id, upload_result.get("message", "Upload failed"), path)
            return {
                "path": path,
                "status": "error",
                "error": upload_result.get("message", "Upload failed"),
            }

    except PermissionError:
        error_message = "Permission denied. Grant Full Disk Access in System Settings > Privacy & Security."
        report_sync_error(local_folder_id, error_message, path)
        return {
            "path": path,
            "status": "error",
            "error": error_message,
        }
    except Exception as e:
        logger.error(f"Error syncing {path}: {e}", exc_info=True)
        report_sync_error(local_folder_id, str(e), path)
        return {
            "path": path,
            "status": "error",
            "error": str(e),
        }


def upload_sync_data(
    local_folder_id: str,
    files: list[dict[str, Any]],
    cursor: dict[str, Any],
    stats: dict[str, Any],
    is_final_batch: bool = True,
) -> dict[str, Any]:
    """
    Upload extracted data to the cloud API.

    Args:
        local_folder_id: UUID of the local folder
        files: List of extracted files with path, content, metadata
        cursor: New cursor after extraction
        stats: Extraction stats

    Returns:
        API response dict
    """
    api_key = get_api_key()
    if not api_key:
        return {"status": "error", "message": "Not authenticated"}

    try:
        client = get_http_client()
        response = _post_with_retries(
            client=client,
            url=f"{get_api_base_url()}/v2/daemon/sync",
            headers={"Authorization": f"Bearer {api_key}"},
            payload={
                "local_folder_id": local_folder_id,
                "files": files,
                "cursor": cursor,
                "stats": stats,
                "is_final_batch": is_final_batch,
            },
        )

        if response is None:
            return {"status": "error", "message": "Request failed after retries"}

        if response.status_code == 200:
            return response.json()
        elif response.status_code == 401:
            return {"status": "error", "message": "Authentication failed"}
        elif response.status_code == 404:
            return {"status": "error", "message": "Local folder not found"}
        elif response.status_code == 413:
            return {"status": "error", "message": "Request too large — try syncing fewer files or smaller batches"}
        else:
            try:
                detail = response.json().get("detail", response.text)
            except ValueError:
                detail = response.text or f"HTTP {response.status_code}"

            # Extract human-readable message from HTML proxy errors (e.g. GCP load balancer)
            if detail and "<html" in detail.lower():
                title_match = re.search(r"<title>(.*?)</title>", detail, re.IGNORECASE)
                if title_match:
                    detail = title_match.group(1).strip()
                else:
                    detail = f"HTTP {response.status_code}"

                # Detect 413-like errors returned by proxies with non-413 status codes
                if "413" in detail or "too large" in detail.lower() or "entity too large" in detail.lower():
                    return {"status": "error", "message": "Request too large — try syncing fewer files or smaller batches"}

            return {"status": "error", "message": f"API error: {detail}"}

    except httpx.TimeoutException:
        return {"status": "error", "message": "Request timeout"}
    except httpx.RequestError as e:
        return {"status": "error", "message": f"Network error: {e}"}


def upload_sync_batches(
    local_folder_id: str,
    files: list[dict[str, Any]],
    cursor: dict[str, Any],
    stats: dict[str, Any],
) -> dict[str, Any]:
    """Upload files in batches and only advance cursor after all succeed."""
    if not files:
        return {"status": "ok", "chunks_indexed": 0}

    batches = list(_iter_batches_by_size(files, MAX_BATCH_SIZE_BYTES, MAX_FILES_PER_BATCH))
    total_batches = len(batches)
    chunks_indexed = 0

    for batch_index, batch in enumerate(batches, start=1):
        is_last_batch = batch_index == total_batches
        result = upload_sync_data(
            local_folder_id=local_folder_id,
            files=batch,
            cursor=cursor if is_last_batch else {},
            stats=stats if is_last_batch else {},
            is_final_batch=is_last_batch,
        )

        if result.get("status") != "ok":
            return result

        chunks_indexed += result.get("chunks_indexed", 0)

    return {"status": "ok", "chunks_indexed": chunks_indexed}


def report_sync_error(local_folder_id: str | None, error: str, path: str | None = None) -> None:
    """Report local sync errors to backend for UI visibility."""
    if not local_folder_id:
        return
    api_key = get_api_key()
    if not api_key:
        return

    try:
        client = get_http_client()
        _post_with_retries(
            client=client,
            url=f"{get_api_base_url()}/v2/daemon/sources/{local_folder_id}/error",
            headers={"Authorization": f"Bearer {api_key}"},
            payload={"error": error, "path": path},
        )
    except Exception:
        logger.debug("Failed to report sync error", exc_info=True)


def _iter_batches_by_size(
    files: list[dict[str, Any]],
    max_bytes: int,
    max_count: int,
):
    """Batch files by total payload size, not just count."""
    batch: list[dict[str, Any]] = []
    batch_size = 0

    for f in files:
        content = f.get("content", "")
        file_size = len(content.encode("utf-8")) if content else 0

        if file_size > max_bytes:
            logger.warning(f"Skipping oversized file: {f.get('path')} ({file_size} bytes)")
            continue

        if batch and (batch_size + file_size > max_bytes or len(batch) >= max_count):
            yield batch
            batch = []
            batch_size = 0

        batch.append(f)
        batch_size += file_size

    if batch:
        yield batch


def _post_with_retries(
    client: httpx.Client,
    url: str,
    headers: dict[str, str],
    payload: dict[str, Any],
) -> httpx.Response | None:
    delay = RETRY_BASE_DELAY
    for attempt in range(MAX_RETRIES):
        try:
            response = client.post(url, headers=headers, json=payload)
            if response.status_code in {429} or response.status_code >= 500:
                raise httpx.HTTPStatusError(
                    f"Retryable status {response.status_code}",
                    request=response.request,
                    response=response,
                )
            return response
        except (httpx.TimeoutException, httpx.RequestError, httpx.HTTPStatusError) as e:
            is_last_attempt = attempt >= MAX_RETRIES - 1
            if is_last_attempt:
                logger.warning(f"POST failed after retries: {e}")
                return None
            jitter = random.uniform(0.8, 1.2)
            time.sleep(min(RETRY_MAX_DELAY, delay) * jitter)
            delay *= 2
