# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

import logging

import pyarrow as pa
from pyarrow import PythonFile
from pyarrow._fs import FileSystemHandler
from pyarrow.fs import FileInfo, FileSelector, FileType

# `JindoFileSystemHandler` (the PyArrow FileIO path) only needs `pyjindo.fs`
# and `pyjindo.util`. The PVFS jindo backend (`create_jindo_oss_filesystem`)
# additionally needs `pyjindo.ossfs`. Track the two surfaces independently so
# that a pyjindosdk build without `pyjindo.ossfs` does not silently disable
# the previously-working PyArrow path.
try:
    import pyjindo.fs as jfs
    import pyjindo.util as jutil
    JINDO_AVAILABLE = True
except ImportError:
    JINDO_AVAILABLE = False
    jfs = None
    jutil = None

try:
    import pyjindo.ossfs as jossfs
    JINDO_OSSFS_AVAILABLE = True
except ImportError:
    jossfs = None
    JINDO_OSSFS_AVAILABLE = False

from pypaimon.common.options import Options
from pypaimon.common.options.config import OssOptions


def build_jindo_config(catalog_options: Options):
    """Build a pyjindo ``Config`` from OSS catalog options.

    Shared by ``JindoFileSystemHandler`` (the PyArrow FileIO path) and
    ``create_jindo_oss_filesystem`` (the PVFS fsspec path) so both jindo entry
    points consume exactly the same credential / endpoint options.
    """
    if not JINDO_AVAILABLE:
        raise ImportError("Module pyjindo is not available. Please install pyjindosdk.")

    config = jutil.Config()

    access_key_id = catalog_options.get(OssOptions.OSS_ACCESS_KEY_ID)
    access_key_secret = catalog_options.get(OssOptions.OSS_ACCESS_KEY_SECRET)
    security_token = catalog_options.get(OssOptions.OSS_SECURITY_TOKEN)
    endpoint = catalog_options.get(OssOptions.OSS_ENDPOINT)
    region = catalog_options.get(OssOptions.OSS_REGION)

    if access_key_id:
        config.set("fs.oss.accessKeyId", access_key_id)
    if access_key_secret:
        config.set("fs.oss.accessKeySecret", access_key_secret)
    if security_token:
        config.set("fs.oss.securityToken", security_token)
    if endpoint:
        endpoint_clean = endpoint.replace('http://', '').replace('https://', '')
        config.set("fs.oss.endpoint", endpoint_clean)
    if region:
        config.set("fs.oss.region", region)
    config.set("fs.oss.user.agent.features", "pypaimon")
    return config


def create_jindo_oss_filesystem(root_uri: str, catalog_options: Options):
    """Create an fsspec-compatible ``JindoOssFileSystem`` for an OSS bucket.

    ``PaimonVirtualFileSystem`` uses this to back OSS reads/writes with the
    native JindoSDK instead of ``ossfs``. JindoSDK writes objects via
    PutObject / multipart upload, so it never issues OSS ``AppendObject`` --
    the call that fails with ``PositionNotEqualToLength`` (409) on the OSS
    data-acceleration endpoint when ``ossfs`` flushes a multi-chunk write.

    ``root_uri`` is the bucket root, e.g. ``oss://my-bucket/``; it must carry
    the bucket so ``JindoOssFileSystem`` can re-attach the ``oss://`` scheme to
    the bucket-relative paths that ``PaimonVirtualFileSystem`` passes in.
    """
    if not (JINDO_AVAILABLE and JINDO_OSSFS_AVAILABLE):
        raise ImportError(
            "pyjindo.ossfs is not available. Please install pyjindosdk>=6.10.4."
        )

    return jossfs.JindoOssFileSystem(
        uri=root_uri,
        config=build_jindo_config(catalog_options),
        # PaimonVirtualFileSystem owns directory semantics for the virtual FS;
        # the backing object-store fs must not auto-create dir-marker objects.
        auto_mkdir=False,
        # Bypass fsspec's _Cached metaclass instance cache, so the only
        # reference to this filesystem -- and to its underlying native jindo
        # connection -- is the PaimonRealStorage cache in PVFS. On token
        # refresh PVFS replaces that entry and the native resources can be
        # released, instead of being pinned forever by fsspec's global cache.
        skip_instance_cache=True,
    )


class JindoInputFile:
    def __init__(self, jindo_stream):
        self._stream = jindo_stream
        self._closed = False

    @property
    def closed(self):
        if hasattr(self._stream, 'closed'):
            return self._stream.closed
        return self._closed

    def read(self, nbytes: int = -1):
        if self.closed:
            raise ValueError("I/O operation on closed file")
        if nbytes is None or nbytes < 0:
            return self._stream.read()
        return self._stream.read(nbytes)

    def seek(self, position: int, whence: int = 0):
        if self.closed:
            raise ValueError("I/O operation on closed file")
        self._stream.seek(position, whence)

    def tell(self) -> int:
        if self.closed:
            raise ValueError("I/O operation on closed file")
        return self._stream.tell()

    def read_at(self, nbytes: int, offset: int):
        if self.closed:
            raise ValueError("I/O operation on closed file")
        return self._stream.pread(nbytes, offset)

    def close(self):
        if not self._closed:
            self._stream.close()
            self._closed = True

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False


class JindoOutputFile:
    def __init__(self, jindo_stream):
        self._stream = jindo_stream
        self._closed = False

    @property
    def closed(self):
        if hasattr(self._stream, 'closed'):
            return self._stream.closed
        return self._closed

    def write(self, data: bytes) -> int:
        if self.closed:
            raise ValueError("I/O operation on closed file")
        if isinstance(data, pa.Buffer):
            data = data.to_pybytes()
        elif not isinstance(data, bytes):
            raise TypeError("Unsupported data type")
        return self._stream.write(data)

    def flush(self):
        if self.closed:
            raise ValueError("I/O operation on closed file")
        if hasattr(self._stream, 'flush'):
            self._stream.flush()

    def close(self):
        if not self._closed:
            self._stream.close()
            self._closed = True

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()
        return False


class JindoFileSystemHandler(FileSystemHandler):
    def __init__(self, root_path: str, catalog_options: Options):
        if not JINDO_AVAILABLE:
            raise ImportError("Module pyjindo is not available. Please install pyjindosdk.")

        self.logger = logging.getLogger(__name__)
        self.root_path = root_path
        self.properties = catalog_options

        config = build_jindo_config(catalog_options)
        self._jindo_fs = jfs.connect(self.root_path, "root", config)

    def __eq__(self, other):
        if isinstance(other, JindoFileSystemHandler):
            return self.root_path == other.root_path
        return NotImplemented

    def __ne__(self, other):
        if isinstance(other, JindoFileSystemHandler):
            return not self.__eq__(other)
        return NotImplemented

    def _normalize_path(self, path: str) -> str:
        if path.startswith('oss://'):
            return path

        if not path or path == '.':
            return self.root_path.rstrip('/') + '/'

        path_clean = path.lstrip('/')
        return self.root_path.rstrip('/') + '/' + path_clean

    def _convert_file_type(self, jindo_type) -> FileType:
        if jindo_type == jfs.FileType.File:
            return FileType.File
        elif jindo_type == jfs.FileType.Directory:
            return FileType.Directory
        else:
            return FileType.Unknown

    def _convert_file_info(self, jindo_info) -> FileInfo:
        pa_type = self._convert_file_type(jindo_info.type)
        return FileInfo(
            path=jindo_info.path,
            type=pa_type,
            size=jindo_info.size if jindo_info.type == jfs.FileType.File else None,
            mtime=jindo_info.mtime if hasattr(jindo_info, 'mtime') else None,
        )

    def get_type_name(self) -> str:
        return "jindo"

    def get_file_info(self, paths) -> list:
        infos = []
        for path in paths:
            normalized = self._normalize_path(path)
            try:
                jindo_info = self._jindo_fs.get_file_info(normalized)
                infos.append(self._convert_file_info(jindo_info))
            except FileNotFoundError:
                infos.append(FileInfo(normalized, FileType.NotFound))
        return infos

    def get_file_info_selector(self, selector: FileSelector) -> list:
        normalized = self._normalize_path(selector.base_dir)
        try:
            items = self._jindo_fs.listdir(normalized, recursive=selector.recursive)
            return [self._convert_file_info(item) for item in items]
        except FileNotFoundError:
            if selector.allow_not_found:
                return []
            raise

    def create_dir(self, path: str, recursive: bool):
        normalized = self._normalize_path(path)
        self._jindo_fs.mkdir(normalized)

    def delete_dir(self, path: str):
        normalized = self._normalize_path(path)
        self._jindo_fs.remove(normalized)

    def delete_dir_contents(self, path: str, missing_dir_ok: bool = False):
        normalized = self._normalize_path(path)
        if normalized == self.root_path:
            raise ValueError(
                "delete_dir_contents() does not accept root path"
            )
        self._delete_dir_contents(path, missing_dir_ok)

    def delete_root_dir_contents(self):
        self._delete_dir_contents("/", missing_dir_ok=False)

    def _delete_dir_contents(self, path: str, missing_dir_ok: bool):
        normalized = self._normalize_path(path)
        try:
            items = self._jindo_fs.listdir(normalized, recursive=False)
        except FileNotFoundError:
            if missing_dir_ok:
                return
            raise
        except Exception as e:
            self.logger.warning(f"Error listing {path}: {e}")
            raise
        for item in items:
            self._jindo_fs.remove(item.path)

    def delete_file(self, path: str):
        normalized = self._normalize_path(path)
        self._jindo_fs.remove(normalized)

    def move(self, src: str, dest: str):
        src_norm = self._normalize_path(src)
        dst_norm = self._normalize_path(dest)
        self._jindo_fs.rename(src_norm, dst_norm)

    def copy_file(self, src: str, dest: str):
        src_norm = self._normalize_path(src)
        dst_norm = self._normalize_path(dest)
        self._jindo_fs.copy_file(src_norm, dst_norm)

    def open_input_stream(self, path: str):
        normalized = self._normalize_path(path)
        jindo_stream = self._jindo_fs.open(normalized, "rb")
        return PythonFile(JindoInputFile(jindo_stream), mode="r")

    def open_input_file(self, path: str):
        normalized = self._normalize_path(path)
        jindo_stream = self._jindo_fs.open(normalized, "rb")
        return PythonFile(JindoInputFile(jindo_stream), mode="r")

    def open_output_stream(self, path: str, metadata):
        normalized = self._normalize_path(path)
        jindo_stream = self._jindo_fs.open(normalized, "wb")
        return PythonFile(JindoOutputFile(jindo_stream), mode="w")

    def open_append_stream(self, path: str, metadata):
        raise IOError("append mode is not supported")

    def normalize_path(self, path: str) -> str:
        return self._normalize_path(path)
