"""HTTP syncer — communicates with the RubberDuck MCP server.

Handles the manifest comparison + file upload protocol:
1. POST /index/manifest — send hashes, get back list of files to upload
2. POST /index/upload — send changed files (gzip compressed, in chunks)
3. GET /index/status — check project index state
"""

import io
import json
import logging
import sys
import tarfile
import time
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

import requests

logger = logging.getLogger(__name__)

# Upload chunking
CHUNK_SIZE = 50  # files per chunk
CHUNK_BYTE_LIMIT = 10_000_000  # 10MB uncompressed per chunk


class SyncError(Exception):
    """Raised when sync fails."""
    pass


def _progress_bar(current: int, total: int, width: int = 30, suffix: str = "") -> str:
    """Render a simple ASCII progress bar."""
    pct = current / total if total else 1.0
    filled = int(width * pct)
    bar = "█" * filled + "░" * (width - filled)
    return f"\r  [{bar}] {current}/{total} {suffix}"


class Syncer:
    """HTTP client for the RubberDuck indexing endpoints."""

    def __init__(self, server_url: str, token: str = "", timeout: int = 300):
        self.server_url = server_url.rstrip("/")
        self.timeout = timeout
        self.session = requests.Session()
        if token:
            self.session.headers["Authorization"] = f"Bearer {token}"

    def compare_manifest(
        self, project: str, file_hashes: Dict[str, str]
    ) -> Dict[str, Any]:
        """Send file hashes to server, get back what needs uploading."""
        url = f"{self.server_url}/index/manifest"
        payload = {"project": project, "files": file_hashes}

        try:
            resp = self.session.post(url, json=payload, timeout=self.timeout)
            resp.raise_for_status()
            return resp.json()
        except requests.RequestException as e:
            raise SyncError(f"Manifest comparison failed: {e}") from e

    def upload_files(
        self,
        project: str,
        files: Dict[str, bytes],
        deleted: Optional[List[str]] = None,
        on_progress: Optional[Callable[[int, int], None]] = None,
    ) -> Dict[str, Any]:
        """Upload changed files to the server in chunks.

        Args:
            project: Project name.
            files: {relative_path: file_bytes}
            deleted: List of paths that were deleted locally.
            on_progress: Callback(files_done, files_total) for progress updates.

        Returns:
            Aggregated server response with sync results.
        """
        if not files and not deleted:
            return {"synced": 0, "message": "Nothing to upload"}

        total_files = len(files)

        # Small batch: single upload (JSON for tiny, gzip for medium)
        if total_files <= CHUNK_SIZE:
            total_size = sum(len(v) for v in files.values())
            if total_files < 10 and total_size < 1_000_000:
                result = self._upload_json(project, files, deleted)
            else:
                result = self._upload_gzip(project, files, deleted)
            if on_progress:
                on_progress(total_files, total_files)
            return result

        # Large batch: upload in chunks with progress
        all_paths = list(files.keys())
        chunks = []
        current_chunk = []
        current_size = 0

        for path in all_paths:
            fsize = len(files[path])
            if current_chunk and (len(current_chunk) >= CHUNK_SIZE or current_size + fsize > CHUNK_BYTE_LIMIT):
                chunks.append(current_chunk)
                current_chunk = []
                current_size = 0
            current_chunk.append(path)
            current_size += fsize

        if current_chunk:
            chunks.append(current_chunk)

        total_synced = 0
        all_graphs = []
        files_done = 0

        for i, chunk_paths in enumerate(chunks):
            chunk_files = {p: files[p] for p in chunk_paths}
            # Only pass deletions with the last chunk
            chunk_deleted = deleted if (i == len(chunks) - 1) else None

            result = self._upload_gzip(project, chunk_files, chunk_deleted)

            total_synced += result.get("synced", 0)
            all_graphs.extend(result.get("graphs_rebuilt", []))
            files_done += len(chunk_paths)

            if on_progress:
                on_progress(files_done, total_files)

        return {
            "synced": total_synced,
            "graphs_rebuilt": all_graphs,
            "chunks": len(chunks),
        }

    def _upload_json(
        self,
        project: str,
        files: Dict[str, bytes],
        deleted: Optional[List[str]],
    ) -> Dict[str, Any]:
        """Upload via JSON (for small batches)."""
        url = f"{self.server_url}/index/upload"

        files_str = {}
        for path, content in files.items():
            try:
                files_str[path] = content.decode("utf-8")
            except UnicodeDecodeError:
                logger.warning(f"Skipping binary file: {path}")
                continue

        payload: Dict[str, Any] = {"project": project, "files": files_str}
        if deleted:
            payload["deleted"] = deleted

        try:
            resp = self.session.post(
                url, json=payload, timeout=self.timeout
            )
            resp.raise_for_status()
            return resp.json()
        except requests.RequestException as e:
            raise SyncError(f"Upload failed: {e}") from e

    def _upload_gzip(
        self,
        project: str,
        files: Dict[str, bytes],
        deleted: Optional[List[str]],
    ) -> Dict[str, Any]:
        """Upload via gzip-compressed tar archive."""
        url = f"{self.server_url}/index/upload"

        buf = io.BytesIO()
        with tarfile.open(fileobj=buf, mode="w:gz") as tar:
            for rel_path, content in files.items():
                info = tarfile.TarInfo(name=rel_path)
                info.size = len(content)
                tar.addfile(info, io.BytesIO(content))

        archive_data = buf.getvalue()

        try:
            resp = self.session.post(
                url,
                data=archive_data,
                headers={
                    "Content-Type": "application/gzip",
                    "X-Project": project,
                },
                timeout=self.timeout,
            )
            resp.raise_for_status()
            result = resp.json()

            if deleted:
                del_resp = self.session.post(
                    f"{self.server_url}/index/remove",
                    json={"project": project, "delete_files": deleted},
                    timeout=self.timeout,
                )
                if del_resp.ok:
                    result["deleted"] = len(deleted)

            return result
        except requests.RequestException as e:
            raise SyncError(f"Upload failed: {e}") from e

    def get_status(self, project: Optional[str] = None) -> Dict[str, Any]:
        """Get index status for a project or list all projects."""
        url = f"{self.server_url}/index/status"
        params = {}
        if project:
            params["project"] = project

        try:
            resp = self.session.get(url, params=params, timeout=self.timeout)
            resp.raise_for_status()
            return resp.json()
        except requests.RequestException as e:
            raise SyncError(f"Status check failed: {e}") from e

    def remove_project(self, project: str) -> Dict[str, Any]:
        """Remove an indexed project from the server."""
        url = f"{self.server_url}/index/remove"
        try:
            resp = self.session.post(
                url, json={"project": project}, timeout=self.timeout
            )
            resp.raise_for_status()
            return resp.json()
        except requests.RequestException as e:
            raise SyncError(f"Project removal failed: {e}") from e

    def list_projects(self) -> Dict[str, Any]:
        """List all indexed projects on the server."""
        return self.get_status()
