"""SFTP connector for file operations with SFTP servers.

Uses paramiko wrapped in asyncio via a thread pool executor.
Maintains a persistent connection with automatic reconnection
protected by an asyncio.Lock to prevent thundering herd.

Requires the ``[sftp]`` extra::

    uv add entr-adapter-core[sftp]

Usage::

    from entr_adapter_core.connectors.sftp import SFTPConnector, SFTPConnectorConfig

    config = SFTPConnectorConfig(
        host="sftp.example.com",
        username="user",
        password="secret",
    )
    connector = SFTPConnector(config)
    files = await connector.list_files("/invoices", extension_filter=".csv")

Or with direct arguments (backward-compatible)::

    connector = SFTPConnector(host="sftp.example.com", username="user", password="secret")
"""

import asyncio
import io
import logging
import os
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Any, Dict, List, Optional, Union

try:
    import paramiko
except ImportError as exc:
    raise ImportError(
        "The SFTP connector requires the 'paramiko' package. "
        "Install it with: uv add entr-adapter-core[sftp]"
    ) from exc

from pydantic import BaseModel, Field

logger = logging.getLogger(__name__)

_executor = ThreadPoolExecutor(max_workers=4)


class SFTPConnectorConfig(BaseModel):
    """Configuration for the SFTP connector.

    Tenant-specific settings for connecting to an SFTP server.

    Attributes:
        host: SFTP server hostname or IP address.
        port: SFTP server port. Standard SFTP uses port 22.
        username: SFTP login username.
        password: SFTP login password.
    """

    host: str = Field(
        ...,
        description="SFTP server hostname or IP address",
    )
    port: int = Field(
        default=22,
        ge=1,
        le=65535,
        description="SFTP server port (default: 22)",
    )
    username: str = Field(
        default="",
        description="SFTP login username",
    )
    password: str = Field(
        ...,
        description="SFTP login password (required)",
    )


class SFTPConnector:
    """Connector for SFTP file operations (list, download, upload, delete).

    Maintains a persistent connection that is reused across requests.
    Reconnection uses exponential backoff and is protected by an asyncio.Lock
    to prevent thundering herd when multiple concurrent requests detect a
    broken connection.

    Accepts either an ``SFTPConnectorConfig`` instance or direct keyword
    arguments for backward compatibility.

    Args:
        config_or_host: An ``SFTPConnectorConfig`` instance, or the SFTP
            server hostname as a string.
        port: SFTP server port (default: 22). Ignored when config is provided.
        username: SFTP login username. Ignored when config is provided.
        password: SFTP login password. Ignored when config is provided.

    Example::

        config = SFTPConnectorConfig(host="sftp.example.com", username="user", password="secret")
        connector = SFTPConnector(config)
        files = await connector.list_files("/invoices")
    """

    def __init__(
        self,
        config_or_host: Union[SFTPConnectorConfig, str] = None,
        port: int = 22,
        username: str = "",
        password: str = "",
        *,
        host: str = None,
    ):
        if isinstance(config_or_host, SFTPConnectorConfig):
            self.host = config_or_host.host
            self.port = config_or_host.port
            self.username = config_or_host.username
            self.password = config_or_host.password
        elif isinstance(config_or_host, str):
            self.host = config_or_host
            self.port = port
            self.username = username
            self.password = password
        elif host is not None:
            self.host = host
            self.port = port
            self.username = username
            self.password = password
        else:
            raise ValueError("SFTP host is required: pass an SFTPConnectorConfig or host string")
        if not self.host:
            raise ValueError("SFTP host is required")
        if not self.password:
            raise ValueError("SFTP password is required")

        # Persistent connection state
        self._transport: Optional[paramiko.Transport] = None
        self._sftp: Optional[paramiko.SFTPClient] = None
        self._reconnect_lock = asyncio.Lock()

    def _is_connected(self) -> bool:
        """Check if the current connection is still alive."""
        if self._transport is None or self._sftp is None:
            return False
        return self._transport.is_active()

    def _connect_sync(self):
        """Establish a new SFTP connection with exponential backoff (up to 3 retries)."""
        self._close_sync()

        last_exception = None
        retry_delay = 2
        max_retries = 3

        for attempt in range(max_retries):
            try:
                logger.info(
                    "Attempting SFTP connection to %s:%d (attempt %d/%d)",
                    self.host, self.port, attempt + 1, max_retries,
                )
                transport = paramiko.Transport((self.host, self.port))
                transport.connect(username=self.username, password=self.password)
                sftp = paramiko.SFTPClient.from_transport(transport)

                self._transport = transport
                self._sftp = sftp
                logger.info("SFTP connection established successfully")
                return

            except Exception as e:
                last_exception = e
                logger.warning("SFTP connection attempt %d failed: %s", attempt + 1, e)
                if attempt < max_retries - 1:
                    logger.info("Retrying in %d seconds...", retry_delay)
                    time.sleep(retry_delay)
                    retry_delay *= 2

        logger.error("Failed to connect to SFTP after %d attempts: %s", max_retries, last_exception)
        raise last_exception

    async def _ensure_connection(self):
        """Ensure a connection exists, reconnecting with lock if needed."""
        # Fast path: connection exists and is active (no lock needed)
        if self._is_connected():
            return

        # Slow path: need to reconnect — lock prevents thundering herd
        async with self._reconnect_lock:
            # Double-check after acquiring lock (another coroutine may have reconnected)
            if self._is_connected():
                return
            loop = asyncio.get_running_loop()
            await loop.run_in_executor(_executor, self._connect_sync)

    def _close_sync(self):
        """Safely close the persistent SFTP connection."""
        if self._sftp is not None:
            try:
                self._sftp.close()
            except Exception:
                pass
            self._sftp = None
        if self._transport is not None:
            try:
                self._transport.close()
            except Exception:
                pass
            self._transport = None

    def close(self):
        """Close the persistent SFTP connection (public API)."""
        self._close_sync()
        logger.info("SFTP connection closed")

    async def _run_sftp_operation(self, operation, *args):
        """Run an SFTP operation with automatic reconnection on failure."""
        await self._ensure_connection()
        sftp = self._sftp  # Capture reference

        def _run():
            return operation(sftp, *args)

        try:
            loop = asyncio.get_running_loop()
            return await loop.run_in_executor(_executor, _run)
        except Exception as e:
            logger.warning("SFTP operation failed, attempting reconnection: %s", e)
            # Mark connection as dead only if it's still the same one we used
            if self._sftp is sftp:
                self._close_sync()
            # Reconnect (lock prevents thundering herd)
            await self._ensure_connection()
            sftp = self._sftp  # Capture new reference

            def _retry():
                return operation(sftp, *args)

            loop = asyncio.get_running_loop()
            return await loop.run_in_executor(_executor, _retry)

    async def list_files(self, remote_path: str = "/", extension_filter: str = "") -> List[Dict[str, Any]]:
        """List files in a remote directory.

        Args:
            remote_path: Remote directory path to list.
            extension_filter: Only return files with this extension (e.g., '.csv').
                Empty string returns all files.

        Returns:
            List of dicts with file information (name, size, modify).
        """
        logger.info("Listing files in %s on SFTP server %s", remote_path, self.host)

        def _op(sftp: paramiko.SFTPClient, path: str) -> List[Dict[str, Any]]:
            filenames = sftp.listdir(path)
            files = []
            for filename in filenames:
                if extension_filter and not filename.endswith(extension_filter):
                    continue
                files.append({
                    "name": filename,
                    "size": 0,
                    "modify": "",
                })
            logger.debug("Found %d files in %s", len(files), path)
            return files

        try:
            files = await self._run_sftp_operation(_op, remote_path)
            logger.info("Found %d files in %s", len(files), remote_path)
            return files
        except Exception as e:
            logger.error("Failed to list files in %s: %s", remote_path, e)
            raise

    async def download_file(self, remote_path: str, local_path: str) -> bool:
        """Download a file from the SFTP server.

        Args:
            remote_path: Full path to the remote file.
            local_path: Full local path where the file should be saved.

        Returns:
            True if download was successful, False otherwise.
        """
        logger.info("Downloading %s from SFTP server %s", remote_path, self.host)

        def _op(sftp: paramiko.SFTPClient, rpath: str, lpath: str) -> bool:
            local_dir = os.path.dirname(lpath)
            if local_dir:
                os.makedirs(local_dir, exist_ok=True)
            sftp.get(rpath, lpath)
            logger.debug("Downloaded %s to %s", rpath, lpath)
            return True

        try:
            result = await self._run_sftp_operation(_op, remote_path, local_path)
            if result:
                logger.info("Successfully downloaded %s to %s", remote_path, local_path)
            return result
        except Exception as e:
            logger.error("Failed to download %s: %s", remote_path, e)
            return False

    async def upload_content(self, content: str, remote_path: str, remote_filename: str) -> Dict[str, Any]:
        """Upload string content directly to the SFTP server.

        Args:
            content: String content to upload.
            remote_path: Remote directory path (e.g., '/orders').
            remote_filename: Name for the file on the remote server.

        Returns:
            Dict containing upload result information.
        """
        logger.info("Uploading content to %s/%s on SFTP server %s", remote_path, remote_filename, self.host)

        def _op(sftp: paramiko.SFTPClient, data: str, rpath: str, rfilename: str) -> Dict[str, Any]:
            content_bytes = data.encode('utf-8')
            bio = io.BytesIO(content_bytes)
            full_remote_path = f"{rpath}/{rfilename}"
            sftp.putfo(bio, full_remote_path)
            logger.debug("Uploaded %s to %s", rfilename, rpath)
            return {
                "server": self.host,
                "path": rpath,
                "filename": rfilename,
                "status": "uploaded",
                "message": f"File {rfilename} successfully uploaded to {rpath}",
                "timestamp": datetime.now().isoformat(),
                "file_size": len(content_bytes),
            }

        try:
            result = await self._run_sftp_operation(_op, content, remote_path, remote_filename)
            logger.info("Successfully uploaded %s to %s", remote_filename, remote_path)
            return result
        except Exception as e:
            logger.error("Failed to upload content to %s/%s: %s", remote_path, remote_filename, e)
            raise

    async def upload_file(self, local_path: str, remote_path: str, remote_filename: str) -> Dict[str, Any]:
        """Upload a local file (binary-safe) to the SFTP server.

        Args:
            local_path: Path to the local file to upload.
            remote_path: Remote directory path.
            remote_filename: Name for the file on the remote server.

        Returns:
            Dict containing upload result information.
        """
        logger.info("Uploading file %s to %s/%s on SFTP server %s", local_path, remote_path, remote_filename, self.host)

        def _op(sftp: paramiko.SFTPClient, lpath: str, rpath: str, rfilename: str) -> Dict[str, Any]:
            full_remote_path = f"{rpath}/{rfilename}"
            sftp.put(lpath, full_remote_path)
            file_size = os.path.getsize(lpath)
            logger.debug("Uploaded %s to %s", rfilename, rpath)
            return {
                "server": self.host,
                "path": rpath,
                "filename": rfilename,
                "status": "uploaded",
                "message": f"File {rfilename} successfully uploaded to {rpath}",
                "timestamp": datetime.now().isoformat(),
                "file_size": file_size,
            }

        try:
            result = await self._run_sftp_operation(_op, local_path, remote_path, remote_filename)
            logger.info("Successfully uploaded %s to %s", remote_filename, remote_path)
            return result
        except Exception as e:
            logger.error("Failed to upload file %s to %s/%s: %s", local_path, remote_path, remote_filename, e)
            raise

    async def delete_file(self, remote_path: str) -> bool:
        """Delete a file from the SFTP server.

        Args:
            remote_path: Full path to the remote file.

        Returns:
            True if deletion was successful (or file didn't exist), False otherwise.
        """
        logger.info("Deleting %s from SFTP server %s", remote_path, self.host)

        def _op(sftp: paramiko.SFTPClient, rpath: str) -> bool:
            try:
                sftp.remove(rpath)
                logger.debug("Deleted %s", rpath)
                return True
            except FileNotFoundError:
                logger.debug("File %s already deleted or doesn't exist", rpath)
                return True
            except IOError as e:
                if 'no such file' in str(e).lower():
                    logger.debug("File %s already deleted or doesn't exist: %s", rpath, e)
                    return True
                raise

        try:
            result = await self._run_sftp_operation(_op, remote_path)
            if result:
                logger.info("Successfully deleted %s", remote_path)
            return result
        except Exception as e:
            logger.error("Failed to delete %s: %s", remote_path, e)
            return False
