"""
Core S3Object class for s3lync.
"""

import os
import re
import shutil
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import contextmanager, suppress
from typing import Any, Callable, Iterator, List, Optional, Tuple, Union

from botocore.exceptions import ClientError

from .client import S3Client
from .exceptions import HashMismatchError, S3ObjectError, SyncError
from .hash import calculate_file_hash
from .logging import get_logger
from .progress import ProgressBar
from .utils import ensure_parent_dir, get_cache_dir, normalize_path, parse_s3_uri

_logger = get_logger("core")


class S3Object:
    """
    Represents an S3 object (file or directory) synchronized with local filesystem.

    Provides automatic upload/download synchronization with MD5 hash
    verification. Handles both files and directories with recursive operations.
    """

    _excludes: list[str]

    def __init__(
        self,
        s3_uri: str,
        local_path: Optional[str] = None,
        boto3_client: Optional[object] = None,
        excludes: Union[str, List[str], None] = None,
        progress_mode: str = "progress",
    ):
        """
        Initialize S3Object.

        Args:
            s3_uri: S3 URI (s3://bucket/key, s3://endpoint@bucket/key, etc)
            local_path: Local file/directory path (optional)
            boto3_client: Pre-configured boto3 S3 client (recommended)
            excludes: Exclude patterns (overrides default if provided)
            progress_mode: Progress display mode: "progress", "compact",
                          "disabled" (default: "progress")

        Example:
            session = boto3.Session(profile_name="dev")
            obj = S3Object("s3://bucket/key", boto3_client=session.client("s3"))
        """
        self.s3_uri = s3_uri
        self.progress_mode = progress_mode
        (
            self.bucket,
            self.key,
            self._access_key,
            self._secret_key,
            self._endpoint,
        ) = parse_s3_uri(s3_uri)
        self._local_path = (
            normalize_path(local_path) if local_path else self._get_default_cache_path()
        )

        # Initialize excludes with default hidden file patterns
        # Or override with user-provided excludes if given
        if excludes:
            # User provided excludes - override defaults completely
            if isinstance(excludes, str):
                self._excludes = [excludes]
            else:  # list
                self._excludes = excludes.copy()
        else:
            # Use default excludes: hidden files, Python cache, and egg info
            self._excludes = [
                r"/\.",  # Hidden files/dirs (.git, .pytest_cache, .venv, etc)
                r"__pycache__",  # Python cache
                r"\.egg-info",  # Egg info
            ]

        # Initialize S3Client
        if boto3_client is not None:
            # Use the provided boto3 client directly
            self._client = S3Client(client=boto3_client)
        else:
            # Create boto3 client from S3 URI credentials and endpoint
            import boto3

            session = boto3.Session(
                aws_access_key_id=self._access_key,
                aws_secret_access_key=self._secret_key,
            )
            created_client = session.client("s3", endpoint_url=self._endpoint)
            self._client = S3Client(client=created_client)

    def _get_default_cache_path(self) -> str:
        """Get default cache path for this S3 object."""
        cache_dir = get_cache_dir()
        return str(cache_dir / self.bucket / self.key)

    @property
    def local_path(self) -> str:
        """Get the local file/directory path."""
        return self._local_path

    def download(
        self,
        use_checksum: bool = True,
        excludes: Union[str, List[str], None] = None,
        progress_mode: Optional[str] = None,
        mirror: bool = False,
    ) -> str:
        """
        Download S3 object (file or directory) to local.

        Args:
            use_checksum: Verify file integrity with MD5 (default: True)
            excludes: Additional regex pattern(s) to exclude (in addition to defaults)
            progress_mode: Progress display mode ("progress", "compact", "disabled").
                          If None, uses object's default
            mirror: When True, makes local identical to remote (default: False)

        Returns:
            Local path

        Raises:
            SyncError: If download fails
            HashMismatchError: If hash verification fails
        """
        # Use provided progress_mode or fall back to object's default
        actual_progress_mode = progress_mode or self.progress_mode

        # Combine self._excludes with excludes parameter
        all_excludes = self._excludes.copy()
        if excludes:
            if isinstance(excludes, str):
                all_excludes.append(excludes)
            elif isinstance(excludes, list):
                all_excludes.extend(excludes)

        try:
            if self._client.is_file(self.bucket, self.key):
                self._download_file(
                    self.key,
                    self._local_path,
                    use_checksum,
                    mirror,
                    progress_mode=actual_progress_mode,
                )
            else:
                self._download_dir(
                    self.key,
                    self._local_path,
                    use_checksum,
                    mirror,
                    all_excludes,
                    actual_progress_mode,
                )
        except SyncError:
            raise
        except Exception as e:
            raise SyncError(f"Download failed: {str(e)}") from e

        return self._local_path

    def _download_file(
        self,
        remote_key: str,
        local_path: str,
        use_checksum: bool,
        mirror: bool,
        callback: Optional[Callable[[int], None]] = None,
        progress_mode: Optional[str] = None,
        progress_position: Optional[int] = None,
        progress_leave: Optional[bool] = None,
    ) -> None:
        """Download single file from S3."""
        ensure_parent_dir(local_path)

        # Check if file exists and is up-to-date
        if not mirror and self._is_equal_file(remote_key, local_path, use_checksum):
            return

        # Download file
        metadata = self._client.download_file(
            self.bucket,
            remote_key,
            local_path,
            callback=callback,
            progress_mode=progress_mode,
            progress_position=progress_position,
            progress_leave=progress_leave,
        )

        if use_checksum:
            if metadata is None:
                raise S3ObjectError(f"Failed to get metadata for {remote_key}")
            etag_value = metadata.get("ETag", "")
            remote_etag: str = str(etag_value).strip('"')
            local_hash = calculate_file_hash(local_path)
            # Skip hash check for multipart uploads (contains '-')
            if "-" not in remote_etag and local_hash != remote_etag:
                raise HashMismatchError(
                    f"Hash mismatch for {remote_key}: "
                    f"local={local_hash}, remote={remote_etag}"
                )

    def _download_dir(
        self,
        remote_prefix: str,
        local_dir: str,
        use_checksum: bool,
        mirror: bool,
        excludes: Union[str, List[str], None] = None,
        progress_mode: Optional[str] = None,
    ) -> None:
        """Download directory recursively from S3."""
        # Use provided progress_mode or fall back to object's default
        actual_progress_mode = progress_mode or self.progress_mode

        # Convert excludes to regex patterns
        exclude_regexes: list[re.Pattern[str]] = []
        if excludes:
            if isinstance(excludes, str):
                exclude_regexes = [re.compile(excludes)]
            elif isinstance(excludes, list):
                exclude_regexes = [re.compile(pattern) for pattern in excludes]

        if not remote_prefix.endswith("/"):
            remote_prefix += "/"

        ensure_parent_dir(local_dir)

        # Handle mirror - delete local files not in remote
        if mirror and os.path.exists(local_dir):
            # Get all remote files recursively
            remote_files = set(
                self._client.list_files(self.bucket, remote_prefix, recursive=True)
            )

            # Delete local files/dirs not in remote
            for root, dirs, files in os.walk(local_dir):
                # Delete files not in remote
                for file in files:
                    file_path = os.path.join(root, file)
                    relative_path = os.path.relpath(file_path, local_dir).replace(
                        "\\", "/"
                    )
                    remote_key = f"{remote_prefix}{relative_path}"

                    if remote_key not in remote_files:
                        with suppress(OSError):
                            os.remove(file_path)

                # Delete empty directories not in remote
                for dir_name in dirs:
                    dir_path = os.path.join(root, dir_name)
                    relative_path = os.path.relpath(dir_path, local_dir).replace(
                        "\\", "/"
                    )
                    remote_dir = f"{remote_prefix}{relative_path}/"

                    # Check if this directory exists in remote
                    has_items = any(f.startswith(remote_dir) for f in remote_files)
                    if not has_items:
                        shutil.rmtree(dir_path, ignore_errors=True)

        # Pre-scan to compute totals (files and bytes)
        total_files = 0
        total_bytes = 0
        paginator = self._client.client.get_paginator("list_objects_v2")
        for result in paginator.paginate(Bucket=self.bucket, Prefix=remote_prefix):
            for file_obj in result.get("Contents", []):
                remote_key = file_obj["Key"]
                if remote_key == remote_prefix:
                    continue
                relative_path = os.path.relpath(remote_key, remote_prefix)
                local_file = os.path.join(local_dir, relative_path)
                if any(regex.search(local_file) for regex in exclude_regexes):
                    continue
                total_files += 1
                with suppress(TypeError, ValueError):
                    total_bytes += int(file_obj.get("Size", 0))

        _logger.info(f"Download: {total_files} files, {total_bytes} bytes")

        # Overall progress bar (bytes-based)
        overall_pbar = ProgressBar(
            total=total_bytes,
            desc="[overall]",
            mode=actual_progress_mode,
            position=0,
            leave=True,
        )

        # Callback factory to avoid closure bug
        def make_callback(pbar: ProgressBar) -> Callable[[int], None]:
            def callback(n: int) -> None:
                with suppress(Exception):
                    pbar.update(n)

            return callback

        overall_callback = make_callback(overall_pbar)

        # Collect all files to download
        files_to_download: List[Tuple[str, str]] = []
        subdirs_to_process: List[Tuple[str, str]] = []

        paginator = self._client.client.get_paginator("list_objects_v2")
        for result in paginator.paginate(
            Bucket=self.bucket, Prefix=remote_prefix, Delimiter="/"
        ):
            for file_obj in result.get("Contents", []):
                remote_key = file_obj["Key"]
                if remote_key == remote_prefix:
                    continue
                relative_path = os.path.relpath(remote_key, remote_prefix)
                local_file = os.path.join(local_dir, relative_path)
                if any(regex.search(local_file) for regex in exclude_regexes):
                    continue
                files_to_download.append((remote_key, local_file))

            for prefix_obj in result.get("CommonPrefixes", []):
                remote_subdir = prefix_obj["Prefix"]
                relative_path = os.path.relpath(remote_subdir, remote_prefix)
                local_subdir = os.path.join(local_dir, relative_path)
                subdirs_to_process.append((remote_subdir, local_subdir))

        # Download files in parallel
        env_max_workers = os.getenv("S3LYNC_MAX_WORKERS")
        try:
            configured_max_workers = int(env_max_workers) if env_max_workers else 8
        except ValueError:
            configured_max_workers = 8
        if configured_max_workers < 1:
            configured_max_workers = 1
        max_workers = (
            min(configured_max_workers, len(files_to_download))
            if files_to_download
            else 1
        )
        first_exception: Optional[Exception] = None
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = [
                executor.submit(
                    self._download_file,
                    remote_key,
                    local_file,
                    use_checksum,
                    mirror,
                    overall_callback,
                    actual_progress_mode,
                    1,
                    False,
                )
                for remote_key, local_file in files_to_download
            ]
            for future in as_completed(futures):
                exc = future.exception()
                if exc and isinstance(exc, Exception):
                    _logger.error(f"Download failed: {exc}")
                    if first_exception is None:
                        first_exception = exc
        if first_exception is not None:
            raise first_exception

        # Process subdirectories recursively
        for remote_subdir, local_subdir in subdirs_to_process:
            self._download_dir(
                remote_subdir,
                local_subdir,
                use_checksum,
                mirror,
                excludes,
                progress_mode,
            )

        # Close overall progress
        with suppress(Exception):
            overall_pbar.close()

    def upload(
        self,
        use_checksum: bool = True,
        excludes: Union[str, List[str], None] = None,
        progress_mode: Optional[str] = None,
        mirror: bool = False,
    ) -> str:
        """
        Upload local object (file or directory) to S3.

        Args:
            use_checksum: Verify file integrity (default: True)
            excludes: Additional regex pattern(s) to exclude (in addition to defaults)
            progress_mode: Progress display mode ("progress", "compact", "disabled").
                          If None, uses object's default
            mirror: When True, makes remote identical to local (default: False)

        Returns:
            S3 URI

        Raises:
            S3ObjectError: If local file/directory doesn't exist
            SyncError: If upload fails
        """
        if not os.path.exists(self._local_path):
            raise S3ObjectError(f"Local path does not exist: {self._local_path}")

        # Use provided progress_mode or fall back to object's default
        actual_progress_mode = progress_mode or self.progress_mode

        # Combine self._excludes with excludes parameter
        all_excludes = self._excludes.copy()
        if excludes:
            if isinstance(excludes, str):
                all_excludes.append(excludes)
            elif isinstance(excludes, list):
                all_excludes.extend(excludes)

        # Convert excludes to regex patterns
        exclude_regexes: list[re.Pattern[str]] = [
            re.compile(pattern) for pattern in all_excludes
        ]

        try:
            if os.path.isfile(self._local_path):
                self._upload_file(
                    self.key,
                    self._local_path,
                    use_checksum,
                    mirror,
                    progress_mode=actual_progress_mode,
                )
            else:
                self._upload_dir(
                    self.key,
                    self._local_path,
                    use_checksum,
                    exclude_regexes,
                    mirror,
                    actual_progress_mode,
                )
        except SyncError:
            raise
        except Exception as e:
            raise SyncError(f"Upload failed: {str(e)}") from e

        return self.s3_uri

    def _upload_file(
        self,
        remote_key: str,
        local_path: str,
        use_checksum: bool,
        mirror: bool,
        callback: Optional[Callable[[int], None]] = None,
        progress_mode: Optional[str] = None,
        progress_position: Optional[int] = None,
        progress_leave: Optional[bool] = None,
    ) -> None:
        """Upload single file to S3."""
        # Check if file exists and is up-to-date
        if not mirror and self._is_equal_file(remote_key, local_path, use_checksum):
            return

        # Upload file
        self._client.upload_file(
            self.bucket,
            remote_key,
            local_path,
            callback=callback,
            progress_mode=progress_mode,
            progress_position=progress_position,
            progress_leave=progress_leave,
        )

    def _upload_dir(
        self,
        remote_prefix: str,
        local_dir: str,
        use_checksum: bool,
        exclude_regexes: List[re.Pattern[str]],
        mirror: bool,
        progress_mode: Optional[str] = None,
    ) -> None:
        """Upload directory recursively to S3."""
        # Use provided progress_mode or fall back to object's default
        actual_progress_mode = progress_mode or self.progress_mode

        if not remote_prefix.endswith("/"):
            remote_prefix += "/"

        # Handle mirror - delete remote files not in local
        if mirror:
            local_files = set()

            for root, _dirs, files in os.walk(local_dir):
                for file in files:
                    file_path = os.path.join(root, file)

                    # Skip excluded files
                    if any(regex.search(file_path) for regex in exclude_regexes):
                        continue

                    relative_path = os.path.relpath(file_path, local_dir).replace(
                        "\\", "/"
                    )
                    local_files.add(f"{remote_prefix}{relative_path}")

            # Delete remote files not in local
            remote_files = self._client.list_files(
                self.bucket, remote_prefix, recursive=True
            )
            for remote_file in remote_files:
                if remote_file not in local_files:
                    self._client.delete_object(self.bucket, remote_file)

        # Pre-scan local files to compute totals
        total_files = 0
        total_bytes = 0
        for root, _dirs, files in os.walk(local_dir):
            for file in files:
                local_file = os.path.join(root, file)
                if any(regex.search(local_file) for regex in exclude_regexes):
                    continue
                total_files += 1
                with suppress(OSError):
                    total_bytes += os.path.getsize(local_file)

        _logger.info(f"Upload: {total_files} files, {total_bytes} bytes")

        # Overall progress bar (bytes-based)
        overall_pbar = ProgressBar(
            total=total_bytes,
            desc="[overall]",
            mode=actual_progress_mode,
            position=0,
            leave=True,
        )

        # Callback factory to avoid closure bug
        def make_callback(pbar: ProgressBar) -> Callable[[int], None]:
            def callback(n: int) -> None:
                with suppress(Exception):
                    pbar.update(n)

            return callback

        overall_callback = make_callback(overall_pbar)

        # Collect files to upload
        files_to_upload: List[Tuple[str, str]] = []
        for root, _dirs, files in os.walk(local_dir):
            for file in files:
                local_file = os.path.join(root, file)
                if any(regex.search(local_file) for regex in exclude_regexes):
                    continue
                relative_path = os.path.relpath(local_file, local_dir).replace(
                    "\\", "/"
                )
                remote_key = f"{remote_prefix}{relative_path}"
                files_to_upload.append((remote_key, local_file))

        # Upload files in parallel
        env_max_workers = os.getenv("S3LYNC_MAX_WORKERS")
        try:
            configured_max_workers = int(env_max_workers) if env_max_workers else 8
        except ValueError:
            configured_max_workers = 8
        if configured_max_workers < 1:
            configured_max_workers = 1
        max_workers = (
            min(configured_max_workers, len(files_to_upload)) if files_to_upload else 1
        )
        first_exception: Optional[Exception] = None
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = [
                executor.submit(
                    self._upload_file,
                    remote_key,
                    local_file,
                    use_checksum,
                    mirror,
                    overall_callback,
                    actual_progress_mode,
                    1,
                    False,
                )
                for remote_key, local_file in files_to_upload
            ]
            for future in as_completed(futures):
                exc = future.exception()
                if exc and isinstance(exc, Exception):
                    _logger.error(f"Upload failed: {exc}")
                    if first_exception is None:
                        first_exception = exc
        if first_exception is not None:
            raise first_exception

        # Close overall progress
        with suppress(Exception):
            overall_pbar.close()

    def _is_equal_file(
        self, remote_key: str, local_path: str, use_checksum: bool
    ) -> bool:
        """Check if remote and local files are equal."""
        if not self._client.is_file(self.bucket, remote_key):
            return False

        if not os.path.isfile(local_path):
            return False

        if not use_checksum:
            return True

        try:
            metadata = self._client.client.head_object(
                Bucket=self.bucket, Key=remote_key
            )
            remote_etag: str = metadata.get("ETag", "")
            remote_etag = remote_etag.strip('"')
            local_hash = calculate_file_hash(local_path)

            # Skip hash check for multipart uploads
            if "-" in remote_etag:
                return True

            return bool(local_hash == remote_etag)
        except (OSError, ClientError):
            return False

    @contextmanager
    def open(self, mode: str = "r", encoding: str = "utf-8") -> Iterator[Any]:
        """
        Context manager to open S3 object as a file.

        Downloads from S3 on enter for read mode, uploads on exit for write mode.

        Args:
            mode: File mode ("r", "w", "rb", "wb", etc.)
            encoding: Text encoding (default: "utf-8")

        Yields:
            File object

        Example:
            with s3_obj.open("w") as f:
                f.write("hello world")
            # Automatically uploaded to S3 on exit
        """
        # Download if reading
        if "r" in mode:
            self.download()

        # Ensure parent directory exists for write modes
        if "w" in mode or "a" in mode or "+" in mode:
            ensure_parent_dir(self._local_path)

        # Open local file
        file_handle = open(
            self._local_path,
            mode=mode,
            encoding=encoding if "b" not in mode else None,
        )

        try:
            yield file_handle
        finally:
            file_handle.close()
            # Upload if writing
            if "w" in mode or "a" in mode or "+" in mode:
                self.upload()

    def exists(self) -> bool:
        """Check if S3 object exists."""
        return self._client.is_file(self.bucket, self.key) or self._client.is_dir(
            self.bucket, self.key
        )

    def delete(self) -> bool:
        """Delete S3 object (file or directory)."""
        return self._client.delete_object(self.bucket, self.key)

    def add_exclude(self, pattern: str) -> "S3Object":
        """
        Add an exclude pattern.

        Args:
            pattern: Regex pattern to exclude

        Returns:
            self for method chaining
        """
        if pattern not in self._excludes:
            self._excludes.append(pattern)
        return self

    def add_excludes(self, patterns: list[str]) -> "S3Object":
        """
        Add multiple exclude patterns.

        Args:
            patterns: List of regex patterns to exclude

        Returns:
            self for method chaining
        """
        for pattern in patterns:
            self.add_exclude(pattern)
        return self

    def remove_exclude(self, pattern: str) -> "S3Object":
        """
        Remove an exclude pattern.

        Args:
            pattern: Regex pattern to remove

        Returns:
            self for method chaining
        """
        if pattern in self._excludes:
            self._excludes.remove(pattern)
        return self

    def clear_excludes(self) -> "S3Object":
        """
        Clear all exclude patterns.

        Returns:
            self for method chaining
        """
        self._excludes.clear()
        return self

    def get_excludes(self) -> list[str]:
        """
        Get all exclude patterns.

        Returns:
            List of exclude patterns
        """
        return self._excludes.copy()

    def __fspath__(self) -> str:
        """
        Return the file system path representation.

        This allows S3Object to be used with open() and pathlib operations.
        Note: Requires manual sync (download/upload) unlike the .open() method.

        Example:
            obj = S3Object("s3://bucket/file.json")
            obj.download()  # Sync from S3
            with open(obj, "r") as f:  # Works like a path!
                data = f.read()
        """
        return self._local_path

    def __repr__(self) -> str:
        """String representation of S3Object."""
        return f"S3Object(s3_uri='{self.s3_uri}', local_path='{self._local_path}')"

    def __str__(self) -> str:
        """String representation of S3Object."""
        return self.s3_uri
