import json
from abc import ABC
from pathlib import Path

import requests
import time
from typing import Dict, Any, Union, BinaryIO, Optional, IO, List
from adxp_sdk.auth import BaseCredentials
from requests.exceptions import RequestException
from adxp_sdk.base import BaseClient as SDKBaseClient

from adxp_sdk.knowledges.enums import LoaderType, SplitterType, KnowledgeUpdateMode
from adxp_sdk.knowledges.schemas import PolicyPayload, TagList, RetrievalOptions


class KnowledgeBaseClientHelper(SDKBaseClient):
    """
    Knowledge SDK specific base client that extends adxp_sdk.base.BaseClient.
    
    Provides additional helper methods for Knowledge operations:
    - _headers(): Get headers with optional extra headers
    - _auth_only_headers(): Get only Authorization header for multipart requests
    - _serialize_root_model_or_raw(): Serialize RootModel or raw data to JSON string
    
    KnowledgeClient and ExternalKnowledgeClient inherit from this class.
    """

    def __init__(self, credentials: BaseCredentials, timeout: float = 30.0):
        """
        Initialize KnowledgeBaseClientHelper.

        Args:
            credentials: Authentication credentials (BaseCredentials)
            timeout: Request timeout in seconds (default: 30.0)
        """
        super().__init__(credentials)
        self.timeout = timeout

    def _make_request(
        self,
        method: str,
        endpoint: str,
        data: Optional[Union[Dict[str, Any], List[Any]]] = None,
        params: Optional[Dict[str, Any]] = None,
        json_data: Optional[Union[Dict[str, Any], List[Any]]] = None,
        files: Optional[Dict[str, Any]] = None,
        max_retries: int = 1,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        Override _make_request to handle file uploads with _auth_only_headers().
        
        For multipart/form-data requests (when files is provided), use _auth_only_headers()
        to let requests automatically set Content-Type.
        """
        # For file uploads, use _auth_only_headers() to allow requests to set Content-Type
        if files:
            # Temporarily store original headers
            original_headers = self.headers
            # Use auth-only headers for multipart requests
            self.headers = self._auth_only_headers()
            try:
                # Call parent _make_request with files parameter
                return super()._make_request(
                    method=method,
                    endpoint=endpoint,
                    data=data,
                    params=params,
                    json_data=json_data,
                    files=files,
                    max_retries=max_retries,
                )
            finally:
                # Restore original headers
                self.headers = original_headers
        else:
            # For non-file requests, use parent implementation
            return super()._make_request(
                method=method,
                endpoint=endpoint,
                data=data,
                params=params,
                json_data=json_data,
                files=files,
                max_retries=max_retries,
            )

    def _make_streaming_request(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict[str, Any]] = None,
        max_retries: int = 1,
    ) -> requests.Response:
        """
        Make HTTP request with streaming response support.
        
        This is a convenience wrapper around _make_request_with_response with stream=True.
        It handles streaming responses (e.g., file downloads) with automatic credential refresh.
        
        Args:
            method: HTTP method (GET, POST, PUT, DELETE)
            endpoint: API endpoint path
            params: Query parameters
            max_retries: Maximum number of retries after refresh (default: 1)
        
        Returns:
            requests.Response: Raw response object for streaming
        
        Raises:
            RequestException: If the API request fails after all retries
        """
        return self._make_request_with_response(
            method=method,
            endpoint=endpoint,
            params=params,
            stream=True,
            timeout=self.timeout,
            max_retries=max_retries
        )

    def _headers(self, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
        """
        Credentials.get_headers() 또는 self.headers 기반 기본 헤더에
        호출 시 전달한 extra 헤더를 병합해서 반환.

        - Authorization, Content-Type, Accept 등은 credentials.get_headers()나
          self.headers 쪽에서 구성한다고 가정.
        - 내부 dict를 오염시키지 않도록 반드시 copy해서 사용.
        """
        if self.credentials:
            base = dict(self.credentials.get_headers() or {})
        else:
            if self.headers is None:
                raise ValueError("Headers are not initialized.")
            base = dict(self.headers)

        if extra:
            base.update(extra)

        return base

    def _auth_only_headers(self) -> Dict[str, str]:
        """
        multipart/form-data 전송 시 사용.
        requests 가 Content-Type을 자동 설정하도록 두고,
        Authorization 만 꺼내 쓴다.
        """
        base = self._headers()
        auth: Dict[str, str] = {}
        if "Authorization" in base:
            auth["Authorization"] = base["Authorization"]
        return auth

    @staticmethod
    def _serialize_root_model_or_raw(value: Any) -> str:
        """
        RootModel (e.g. TagList, PolicyPayload) 이면 model_dump() 후 JSON 문자열로,
        이미 dict/list/etc 이면 그대로 JSON 문자열로 직렬화.
        """
        if hasattr(value, "model_dump"):
            payload = value.model_dump(mode="json", exclude_none=True)
        else:
            payload = value
        return json.dumps(payload, ensure_ascii=False)


class KnowledgeClient(KnowledgeBaseClientHelper):
    """
    Knowledge Client for managing knowledge bases (repositories, collections, documents).

    This client provides comprehensive functionality for managing knowledge bases including:
    - Knowledge repository CRUD operations
    - Collection management
    - Document management and indexing
    - External knowledge base management
    - Retrieval operations

    How to use:
        >>> from adxp_sdk.auth import TokenCredentials
        >>> from adxp_sdk.knowledges import KnowledgeClient
        >>> 
        >>> # Initialize client with credentials
        >>> credentials = TokenCredentials(
        ...     base_url="https://aip.sktai.io",
        ...     username="user",
        ...     password="pw",
        ...     client_id="cid"
        ... )
        >>> client = KnowledgeClient(credentials=credentials)
        >>> 
        >>> # List all knowledge repositories
        >>> repos = client.list_knowledge(page=1, size=10)
        >>> 
        >>> # Get a specific knowledge repository
        >>> repo = client.get_knowledge("repo_id")
        >>> 
        >>> # Create a new knowledge repository
        >>> new_repo = client.create_knowledge(
        ...     name="My Knowledge Base",
        ...     description="Description",
        ...     embedding_model_name="text-embedding-ada-002",
        ...     vector_db_id="vector_db_uuid",
        ...     loader=LoaderType.Default,
        ...     splitter=SplitterType.RecursiveCharacter
        ... )
        >>> 
        >>> # Update a knowledge repository
        >>> updated = client.update_knowledge("repo_id", {"description": "Updated description}"})
        >>> 
        >>> # Delete a knowledge repository
        >>> client.delete_knowledge("repo_id")
    """

    def list_knowledge(
            self,
            page: int = 1,
            size: int = 10,
            fetch_mode: str = "lazy",
            is_active: bool | None = None,
            sort: str | None = None,
            filter: str | None = None,
            search: str | None = None            
    ) -> Union[Dict[str, Any], List[Any], Any]:

        try:
            params = {
                "is_active": is_active,
                "page": page,
                "size": size,
                "sort": sort,
                "filter": filter,
                "search": search,
                "fetch_mode": fetch_mode
            }

            return self._make_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos",
                params=params
            )
        except RequestException as e:
            raise RequestException(f"Failed to list knowledges: {str(e)}")

    def get_knowledge(
            self,
            repo_id: str,
    ) -> Union[Dict[str, Any], List[Any], Any]:

        # UUID 객체가 넘어와도 문자열로 치환
        repo_id_str = str(repo_id)

        try:
            return self._make_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos/{repo_id_str}"
            )
        except RequestException as e:
            raise RequestException(f"Failed to get knowledge: {str(e)}")

    def create_knowledge(
            self,
            name: str,
            embedding_model_name: str,
            vector_db_id: str,
            loader: LoaderType,
            splitter: SplitterType,
            datasource_id: str | None = None,
            description: str | None = None,
            chunk_size: int | None = None,
            chunk_overlap: int | None = None,
            separator: str | None = None,
            custom_loader_id: str | None = None,
            custom_splitter_id: str | None = None,
            tool_id: str | None = None,
            processor_ids: list[str] | None = None,
    ) -> Union[Dict[str, Any], List[Any], Any]:

        try:

            # 1. 기존 데이터소스 ID가 없으면 데이터소스 ID 생성
            if datasource_id is None:
                datasource_name = f"datasource_{name}_{int(time.time())}"
                payload = {
                    "name": datasource_name
                }
                result = self._make_request(
                    method="POST",
                    endpoint="/api/v1/datasources",  # Full path for non-knowledge API
                    json_data=payload
                )
                if not isinstance(result, dict):
                    raise RequestException(f"Unexpected response type: expected dict, got {type(result)}")
                datasource_id = result.get("id")

            # 2. Knowledge 리포지토리 생성
            payload = {
                "name": name,
                "description": description,
                "datasource_id": datasource_id,
                "embedding_model_name": embedding_model_name,
                "loader": loader,
                "splitter": splitter,
                "vector_db_id": vector_db_id,
                "chunk_size": chunk_size,
                "chunk_overlap": chunk_overlap,
                "separator": separator,
                "custom_loader_id": custom_loader_id,
                "custom_splitter_id": custom_splitter_id,
                "tool_id": tool_id,
                "processor_ids": processor_ids,

            }

            # 3. None 값인 항목 제거
            payload = {k: v for k, v in payload.items() if v is not None}

            return self._make_request(
                method="POST",
                endpoint=f"api/v1/knowledge/repos",
                json_data=payload
            )

        except requests.exceptions.RequestException as e:
            raise RequestException(f"Failed to create knowledge: {str(e)}")

    def update_knowledge(
            self,
            repo_id: str,
            name: str | None = None,
            description: str | None = None,
            loader: LoaderType | None = None,
            splitter: SplitterType | None = None,
            chunk_size: int | None = None,
            chunk_overlap: int | None = None,
            separator: str | None = None,
            custom_loader_id: str | None = None,
            custom_splitter_id: str | None = None,
            tool_id: str | None = None,
            processor_ids: list[str] | None = None,
    ) -> Union[Dict[str, Any], List[Any], Any]:

        try:

            # 1. 기존 데이터 가져오기
            current_data = self._make_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos/{repo_id}"
            )

            # 2. 파라미터 값이 있으면 파라미터 값, 없으면 기존 데이터 값으로 payload 생성
            payload: Dict[str, Any] = {
                "name": name,
                "description": description,
                "loader": (
                    (loader.value if isinstance(loader, LoaderType) else loader)
                    or (current_data.get("loader") if isinstance(current_data, dict) else None)
                ),
                "splitter": (
                    (splitter.value if isinstance(splitter, SplitterType) else splitter)
                    or (current_data.get("splitter") if isinstance(current_data, dict) else None)
                ),
                "chunk_size": chunk_size,
                "chunk_overlap": chunk_overlap,
                "separator": separator,
                "custom_loader_id": custom_loader_id,
                "custom_splitter_id": custom_splitter_id,
                "tool_id": tool_id,
                "processor_ids": processor_ids,
            }

            try:
                return self._make_request(
                    method="PUT",
                    endpoint=f"api/v1/knowledge/repos/{repo_id}/edit",
                    json_data=payload
                )
            except RequestException:
                # 빈 응답 처리
                return {"message": "Successfully updated knowledge", "repo_id": repo_id}

        except RequestException as e:
            raise RequestException(f"Failed to update knowledge: {str(e)}")

    def delete_knowledge(
            self,
            repo_id: str
    ) -> Union[Dict[str, Any], List[Any], Any]:
        try:
            try:
                return self._make_request(
                    method="DELETE",
                    endpoint=f"api/v1/knowledge/repos/{repo_id}"
                )
            except RequestException:
                # 빈 응답 처리
                return {"repo_id": repo_id}

        except RequestException as e:
            raise RequestException(f"Failed to delete knowledge: {str(e)}")

    def upload_knowledge_file(
            self,
            repo_id: str,
            datasource_id: str,
            file_path: str,
            update_mode: KnowledgeUpdateMode = KnowledgeUpdateMode.APPEND_MODIFIED_DOCS,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        Upload file to Knowledge repository and trigger indexing.
        
        Args:
            repo_id: Knowledge repository ID
            datasource_id: Datasource ID
            file_path: Path to file to upload
            update_mode: Update mode (default: APPEND_MODIFIED_DOCS)
                - APPEND_MODIFIED_DOCS: Append documents to existing collection
                - ADD_NEW_COLLECTION: Create new collection (replaces index)
        
        Returns:
            Dictionary containing repo_id, datasource_id, file_path, and repo_task_id
        
        Raises:
            RequestException: If the request fails
        """
        try:

            # 1. 파일 업로드
            upload_result = self.upload_file(
                file_path=file_path
            )

            # 2. 업로드 된 파일 ID 추출
            if not isinstance(upload_result, dict) or "data" not in upload_result:
                raise RequestException(f"Unexpected upload result format: {type(upload_result)}")
            upload_data = upload_result["data"]
            if not isinstance(upload_data, list) or len(upload_data) == 0:
                raise RequestException("Upload result data is not a list or is empty")
            if not isinstance(upload_data[0], dict):
                raise RequestException("Upload result data[0] is not a dict")
            
            get_id_result = self.get_by_file_id(
                datasource_id=datasource_id,
                file_name=upload_data[0]["file_name"],
            )
            if not isinstance(get_id_result, dict):
                raise RequestException(f"Unexpected get_id_result format: {type(get_id_result)}")

            # 3. 데이터소스 업데이트
            self.update_datasource(
                datasource_id=datasource_id,
                file_id=get_id_result["file_id"],
                file_name=upload_data[0]["file_name"],
                temp_file_path=upload_data[0]["temp_file_path"],
            )

            # 4. Knowledge 리포지토리 파일 List 업데이트
            self.update_knowledge_datasource(
                repo_id=repo_id,
                update_mode=update_mode
            )

            # 5. Knowledge 리포지토리 파일 인덱싱 실행
            indexing_result = self.indexing_knowledge(
                repo_id=repo_id,
                target_step="embedding_and_indexing"
            )
            if not isinstance(indexing_result, dict):
                raise RequestException(f"Unexpected indexing_result format: {type(indexing_result)}")
            repo_task_id = indexing_result.get("repo_task_id")

            return {
                "repo_id": repo_id,
                "datasource_id": datasource_id,
                "file_path": file_path,
                "repo_task_id": repo_task_id,
            }

        except requests.exceptions.RequestException as e:
            raise RequestException(f"Failed to upload knowledge file: {str(e)}")

    def upload_file(self, file_path: str) -> Union[Dict[str, Any], List[Any], Any]:

        try:
            # 1. 파일 업로드
            with open(file_path, 'rb') as file:
                files = {'files': file}
                try:
                    return self._make_request(
                        method="POST",
                        endpoint="/api/v1/datasources/upload/files",  # Full path for non-knowledge API
                        files=files
                    )
                except RequestException:
                    return {"message": "Successfully Upload File", "file_path": file_path}
        except RequestException as e:
            raise RequestException(f"Failed to Upload File: {e}")

    def get_by_file_id(self, datasource_id: str, file_name: str) -> Union[Dict[str, Any], List[Any], Any]:
        try:
            params = {
                "file_name": file_name
            }
            try:
                return self._make_request(
                    method="GET",
                    endpoint=f"/api/v1/datasources/{datasource_id}/files/queries/name",
                    params=params
                )
            except RequestException:
                return {"message": "Successfully retrieved file by Id", "datasource_id": datasource_id}
        except RequestException as e:
            raise RequestException(f"Failed to retrieve file by Id : {str(e)}")

    def update_datasource(
            self,
            datasource_id: str,
            file_id: str,
            file_name: str,
            temp_file_path: str
    ) -> Union[Dict[str, Any], List[Any], Any]:
        try:
            payload = {
                "id": datasource_id,
                "type": "file",
                "modified_files": [
                    {
                        "file_id": file_id,
                        "file_name": file_name,
                        "temp_file_path": temp_file_path,
                        "status": "added"
                    }
                ]
            }

            try:
                return self._make_request(
                    method="PUT",
                    endpoint=f"/api/v1/datasources/{datasource_id}",
                    json_data=payload
                )
            except RequestException:
                return {"message": "Successfully updated datasource", "datasource_id": datasource_id}
        except RequestException as e:
            raise RequestException(f"Failed to update datasource: {str(e)}")

    def update_knowledge_datasource(
        self,
        repo_id: str,
        update_mode: KnowledgeUpdateMode = KnowledgeUpdateMode.APPEND_MODIFIED_DOCS,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        Update Knowledge repository with datasource changes.
        
        Args:
            repo_id: Knowledge repository ID
            update_mode: Update mode (default: APPEND_MODIFIED_DOCS)
                - APPEND_MODIFIED_DOCS: Append documents to existing collection
                - ADD_NEW_COLLECTION: Create new collection (replaces index)
        
        Returns:
            Dictionary containing update result
        
        Raises:
            RequestException: If the request fails
        """
        try:
            payload = {
                "update_mode": update_mode.value
            }
            try:
                return self._make_request(
                    method="PUT",
                    endpoint=f"api/v1/knowledge/repos/{repo_id}",
                    json_data=payload
                )
            except RequestException:
                return {"message": "Successfully updated knowledge datasource", "repo_id": repo_id}
        except RequestException as e:
            raise RequestException(f"Failed to update knowledge datasource: {str(e)}")

    def indexing_knowledge(self, repo_id: str, target_step: str) -> Union[Dict[str, Any], List[Any], Any]:

        try:
            payload = {
                "target_step": target_step
            }
            try:
                return self._make_request(
                    method="POST",
                    endpoint=f"api/v1/knowledge/repos/{repo_id}/indexing",
                    json_data=payload
                )
            except RequestException:
                return {"message": "Successfully started indexing knowledge file", "repo_id": repo_id}
        except RequestException as e:
            raise RequestException(f"Failed to start indexing knowledge file: {str(e)}")

    def list_documents(
        self,
        repo_id: str,
        page: int = 1,
        size: int = 10,
        sort: Optional[str] = None,
        filter: Optional[str] = None,
        search: Optional[str] = None,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        List documents in a Knowledge repository.
        
        Args:
            repo_id: Knowledge repository ID
            page: Page number (default: 1)
            size: Page size (default: 10)
            sort: Sort parameter (e.g., "created_at,desc")
            filter: Filter parameter (e.g., "status:embedded")
            search: Search parameter
        
        Returns:
            Dictionary containing document list and pagination info
        
        Raises:
            RequestException: If the request fails
        """
        try:
            params: Dict[str, Any] = {
                "page": page,
                "size": size,
            }
            if sort is not None:
                params["sort"] = sort
            if filter is not None:
                params["filter"] = filter
            if search is not None:
                params["search"] = search

            return self._make_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos/{repo_id}/documents",
                params=params
            )
        except RequestException as e:
            raise RequestException(f"Failed to list documents: {str(e)}")

    def get_document(
        self,
        repo_id: str,
        document_id: str,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        Get document details by ID.
        
        Args:
            repo_id: Knowledge repository ID
            document_id: Document ID
        
        Returns:
            Dictionary containing document details including latest_task
        
        Raises:
            RequestException: If the request fails
        """
        try:
            return self._make_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos/{repo_id}/documents/{document_id}",
            )
        except RequestException as e:
            raise RequestException(f"Failed to get document: {str(e)}")

    def get_task_status(
        self,
        repo_id: str,
        task_id: str,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        Get task status by task ID.
        
        Args:
            repo_id: Knowledge repository ID
            task_id: Task ID (repo_task_id)
        
        Returns:
            Dictionary containing task status and document histories
        
        Raises:
            RequestException: If the request fails
        """
        try:
            return self._make_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos/{repo_id}/tasks/{task_id}",
            )
        except RequestException as e:
            raise RequestException(f"Failed to get task status: {str(e)}")


class ExternalKnowledgeClient(KnowledgeBaseClientHelper):
    """SDK for managing External Knowledges"""

    def _get_retrieval_script_template(self) -> requests.Response:
        try:
            resp = self._make_streaming_request(
                method="GET",
                endpoint=f"api/v1/knowledge/repos/external/template"
            )
        except requests.Timeout as e:
            raise RequestException(f"Timeout while downloading template: {e}") from e
        except requests.RequestException as e:
            raise RequestException(f"Network error while downloading template: {e}") from e

        if not (200 <= resp.status_code < 300):
            try:
                data = resp.json()
                msg = data.get("message") or data.get("detail") or str(data)
            except Exception:
                msg = resp.text[:500]
            raise RequestException(f"Failed to get retrieval script template.({resp.status_code}): {msg}")

        return resp

    def download_retrieval_script_template(
            self,
            dest: Union[str, Path, BinaryIO],
            *,
            chunk_size: int = 1024 * 64,
    ) -> None:
        """
        retrieval_script_template.py 를 dest 위치에 저장.

        Args:
            dest: 저장할 로컬 경로(str/Path) 또는 BinaryIO
            chunk_size: download 처리 시 chunk 크기
        """
        resp = self._get_retrieval_script_template()

        try:
            # 경로인 경우
            if isinstance(dest, (str, Path)):
                path = Path(dest)
                path.parent.mkdir(parents=True, exist_ok=True)
                with path.open("wb") as f:
                    for chunk in resp.iter_content(chunk_size=chunk_size):
                        if chunk:
                            f.write(chunk)
                return

            # BinaryIO 인 경우
            if hasattr(dest, "write"):
                for chunk in resp.iter_content(chunk_size=chunk_size):
                    if chunk:
                        dest.write(chunk)
                return

            raise RequestException(f"Unsupported dest type: {type(dest)}")

        except OSError as e:
            raise RequestException(f"File write error: {e}") from e

    def create_external_knowledge(
            self,
            name: str,
            embedding_model_name: str,
            vector_db_id: str,
            index_name: str,
            script_file: Union[str, Path, IO[bytes]],
            description: Optional[str] = None,
            tags: Optional[TagList] = None,
            policy: Optional[PolicyPayload] = None,
            **extra_args: Any,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        POST /api/v1/knowledge/repos/external

        multipart/form-data 필드:
          - name*                  (text)
          - description            (text)
          - embedding_model_name*  (text)
          - vector_db_id*          (text, UUID)
          - index_name*            (text)
          - script_file*           (file, binary)
          - tags                   (text, JSON)
          - policy                 (text, JSON)

        Returns:
          JSON dict (e.g. {"repo_id": "...}"}).
        """
        url = f"{self.base_url}/api/v1/knowledge/repos/external"

        data: Dict[str, Any] = {
            "name": name,
            "embedding_model_name": embedding_model_name,
            "vector_db_id": str(vector_db_id),
            "index_name": index_name,
        }
        if description is not None:
            data["description"] = description

        if tags is not None:
            data["tags"] = self._serialize_root_model_or_raw(tags)

        if policy is not None:
            data["policy"] = self._serialize_root_model_or_raw(policy)

        # 이미 정의된 필드를 덮어쓰지 않는 선에서 extra_args 추가
        for key, value in extra_args.items():
            if key not in data:
                data[key] = value

        # script_file: path 또는 file-like 모두 지원
        def _open_file_if_needed(
                f: Union[str, Path, IO[bytes]]
        ) -> tuple[IO[bytes], bool]:
            # 파일 객체인지 확인 (str이나 Path가 아닌 경우)
            if not isinstance(f, (str, Path)) and hasattr(f, "read"):
                # f가 파일 객체인 경우
                return f, False  # type: ignore[return-value]  # 이미 열린 파일
            # f가 파일 객체가 아닌 경우에만 Path로 변환
            if isinstance(f, (str, Path)):
                p = Path(f)
                return p.open("rb"), True
            # 이 경우는 발생하지 않아야 하지만 타입 체커를 위해
            raise TypeError(f"Expected str, Path, or file-like object, got {type(f)}")

        file_obj, should_close = _open_file_if_needed(script_file)
        files = {
            # ("필드명": (파일명, 파일객체))
            "script_file": (
                getattr(file_obj, "name", "script.py}"),
                file_obj,
            )
        }

        try:
            return self._make_request(
                method="POST",
                endpoint=f"api/v1/knowledge/repos/external",
                data=data,
                files=files
            )
        finally:
            if should_close:
                file_obj.close()

    def list_external_knowledges(
            self,
            page: int = 1,
            size: int = 10,
            is_active: Optional[bool] = None,
            sort: Optional[str] = None,
            filter: Optional[str] = None,
            search: Optional[str] = None,
            **extra_params: Any,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        GET /api/v1/knowledge/repos/external

        Project에 등록된 External Knowledge Repo 목록을 조회합니다.

        Query Parameters:
          - page       (int, optional, default: 1)
          - size       (int, optional, default: 10)
          - is_active  (bool, optional)
          - sort       (str, optional)
          - filter     (str, optional)
          - search     (str, optional)  # name, description 에 멀티 단어 포함 검색
          - extra_params (kwargs)       # 향후 추가 쿼리 파라미터 대응용

        Returns:
          - 서버에서 반환하는 JSON dict 그대로 (예: {"data": [...], "pagination": {...}})
        """
        url = f"{self.base_url}/api/v1/knowledge/repos/external"

        params: Dict[str, Any] = {
            "page": page,
            "size": size,
        }

        if is_active is not None:
            params["is_active"] = is_active
        if sort is not None:
            params["sort"] = sort
        if filter is not None:
            params["filter"] = filter
        if search is not None:
            params["search"] = search

        # 아직 SDK에 정의되지 않은 추가 쿼리 파라미터도 안전하게 전달
        for key, value in extra_params.items():
            if key not in params:
                params[key] = value

        return self._make_request(
            method="GET",
            endpoint=f"api/v1/knowledge/repos/external",
            params=params
        )

    def get_external_knowledge(
            self,
            repo_id: str,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        GET /api/v1/knowledge/repos/external/{repo_id}

        선택한 External Knowledge Repo 상세 정보를 조회합니다.

        Path Parameters:
          - repo_id (str | UUID): 조회할 External Repo ID

        Returns:
          - 서버에서 반환하는 JSON dict 그대로
            (예: {
                "name": "...",
                "description": "...",
                "embedding_model_id": "...",
                "vector_db_id": "...",
                "index_name": "...",
                "script": "...",
                "id": "...",
                "created_at": "...",
                "updated_at": "...",
                "is_active": true,
                "vector_db_type": "...",
                "vector_db_name": "...",
                "embedding_model_name": "...",
                "tags": [ ... ],
                ...
              })
        """
        # UUID 객체가 넘어와도 문자열로 치환
        repo_id_str = str(repo_id)

        return self._make_request(
            method="GET",
            endpoint=f"api/v1/knowledge/repos/external/{repo_id_str}"
        )

    def update_external_knowledge(
            self,
            repo_id: str,
            name: Optional[str] = None,
            description: Optional[str] = None,
            embedding_model_name: Optional[str] = None,
            index_name: Optional[str] = None,
            script_file: Optional[Union[str, Path, BinaryIO]] = None,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        PUT /api/v1/knowledge/repos/external/{repo_id}

        Request body (multipart/form-data):
          - name                (optional, string)
          - description         (optional, string)
          - embedding_model_name(optional, string)
          - index_name          (optional, string)
          - script_file         (optional, file - retrieval script)
        """
        if (
                name is None
                and description is None
                and embedding_model_name is None
                and index_name is None
                and script_file is None
        ):
            raise ValueError("At least one field must be provided to update.")

        url = f"{self.base_url}/api/v1/knowledge/repos/external/{repo_id}"

        # text 필드만 모으기
        form_fields: Dict[str, Any] = {}
        if name is not None:
            form_fields["name"] = name
        if description is not None:
            form_fields["description"] = description
        if embedding_model_name is not None:
            form_fields["embedding_model_name"] = embedding_model_name
        if index_name is not None:
            form_fields["index_name"] = index_name

        # multipart/form-data 강제: text 필드도 files 형식으로 넣기
        files: Dict[str, Any] = {k: (None, v) for k, v in form_fields.items()}

        file_obj: Optional[BinaryIO] = None
        should_close = False

        if script_file is not None:
            # path / BinaryIO 모두 지원
            def _open_file_if_needed(
                    f: Union[str, Path, BinaryIO]
            ) -> tuple[BinaryIO, bool]:
                # 파일 객체인지 확인 (str이나 Path가 아닌 경우)
                if not isinstance(f, (str, Path)) and hasattr(f, "read"):
                    # f가 파일 객체인 경우
                    return f, False  # type: ignore[return-value, arg-type]  # 이미 열린 파일
                # f가 파일 객체가 아닌 경우에만 Path로 변환
                if isinstance(f, (str, Path)):
                    p = Path(f)
                    return p.open("rb"), True
                # 이 경우는 발생하지 않아야 하지만 타입 체커를 위해
                raise TypeError(f"Expected str, Path, or file-like object, got {type(f)}")

            file_obj, should_close = _open_file_if_needed(script_file)
            files["script_file"] = (
                getattr(file_obj, "name", "retrieval_script.py"),
                file_obj,
            )

        try:
            return self._make_request(
                method="PUT",
                endpoint=f"api/v1/knowledge/repos/external/{repo_id}",
                files=files
            )
        except RequestException as e:
            # 응답 바디 일부를 함께 남겨 디버깅 용이하게
            raise RequestException(
                f"Failed to update external repo: {e}"
            )
        finally:
            if should_close and file_obj is not None:
                file_obj.close()

    def delete_external_knowledge(
            self,
            repo_id: str
    ) -> Union[Dict[str, Any], List[Any], Any]:
        try:
            try:
                return self._make_request(
                    method="DELETE",
                    endpoint=f"api/v1/knowledge/repos/external/{repo_id}"
                )
            except RequestException:
                return {"repo_id": repo_id}

        except RequestException as e:
            raise RequestException(f"Failed to delete knowledge: {str(e)}")

    def test_external_knowledge(
            self,
            embedding_model_name: str,
            vector_db_id: str,
            index_name: str,
            script_file: Union[str, Path, IO[bytes]],
            query: str,
            retrieval_options: Optional[RetrievalOptions] = None,
            **extra_args: Any,
    ) -> Union[Dict[str, Any], List[Any], Any]:
        """
        POST /api/v1/knowledge/repos/external/test

        외부에서 생성된 Knowledge에 대한 설정이 올바른지 테스트하는 API.

        multipart/form-data 필드:
          - embedding_model_name*   (text)
          - vector_db_id*           (text, UUID)
          - index_name*             (text)
          - script_file*            (file, binary)
          - query*                  (text)
          - retrieval_options       (text, JSON)

        Returns:
          JSON dict (예: {"status": "success", "detail": [...]}).
        """
        url = f"{self.base_url}/api/v1/knowledge/repos/external/test"

        data: Dict[str, Any] = {
            "embedding_model_name": embedding_model_name,
            "vector_db_id": str(vector_db_id),
            "index_name": index_name,
            "query": query,
        }

        if retrieval_options is not None:
            data["retrieval_options"] = self._serialize_root_model_or_raw(
                retrieval_options
            )

        # 이미 정의된 키를 덮어쓰지 않는 선에서 extra_args 추가
        for key, value in extra_args.items():
            if key not in data:
                data[key] = value

        # script_file: path 또는 file-like 모두 지원
        def _open_file_if_needed(
                f: Union[str, Path, IO[bytes]]
        ) -> tuple[IO[bytes], bool]:
            # 파일 객체인지 확인 (str이나 Path가 아닌 경우)
            if not isinstance(f, (str, Path)) and hasattr(f, "read"):
                # f가 파일 객체인 경우
                return f, False  # type: ignore[return-value, arg-type]  # 이미 열린 파일
            # f가 파일 객체가 아닌 경우에만 Path로 변환
            if isinstance(f, (str, Path)):
                p = Path(f)
                return p.open("rb"), True
            # 이 경우는 발생하지 않아야 하지만 타입 체커를 위해
            raise TypeError(f"Expected str, Path, or file-like object, got {type(f)}")

        file_obj, should_close = _open_file_if_needed(script_file)

        files = {
            "script_file": (
                getattr(file_obj, "name", "script.py}"),
                file_obj,
            )
        }

        try:
            return self._make_request(
                method="POST",
                endpoint=f"api/v1/knowledge/repos/external/test",
                data=data,
                files=files
            )
        except RequestException as e:
            # 응답 일부 포함해서 디버깅 도움
            raise RequestException(
                f"Failed to test external repo: {e}"
            )
        finally:
            if should_close:
                file_obj.close()
