"""
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
import uuid
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,
            connector_type=detected_type,
        )

        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,
    connector_type: str | None = None,
    idempotency_key: str | None = None,
) -> 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
        connector_type: Source connector type (e.g. "imessage", "whatsapp")
        idempotency_key: Unique key for this batch to prevent duplicate processing on retries

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

    try:
        client = get_http_client()
        payload = {
            "local_folder_id": local_folder_id,
            "files": files,
            "cursor": cursor,
            "stats": stats,
            "is_final_batch": is_final_batch,
        }
        if connector_type:
            payload["connector_type"] = connector_type
        if idempotency_key:
            payload["idempotency_key"] = idempotency_key

        response = _post_with_retries(
            client=client,
            url=f"{get_api_base_url()}/v2/daemon/sync",
            headers={"Authorization": f"Bearer {api_key}"},
            payload=payload,
        )

        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],
    connector_type: str | None = None,
) -> dict[str, Any]:
    """Upload files in batches and only advance cursor after all succeed.

    Each batch gets a unique idempotency key so retries are safe.
    """
    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
    # Generate a sync run ID so batch keys are unique per run
    sync_run_id = uuid.uuid4().hex[:12]

    for batch_index, batch in enumerate(batches, start=1):
        is_last_batch = batch_index == total_batches
        idempotency_key = f"{local_folder_id}:{sync_run_id}:b{batch_index}"

        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,
            connector_type=connector_type,
            idempotency_key=idempotency_key,
        )

        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
