from __future__ import annotations

import base64
import inspect
import json
import os
import re
import subprocess
import sys
import tempfile
import types
import warnings
from os import getenv
from typing import (
    TYPE_CHECKING,
    Any,
    Generator,
    Literal,
    Mapping,
    Sequence,
    TypeAlias,
    Unpack,
)

from openai import OpenAI
from openai.resources.vector_stores.vector_stores import VectorStores
from openai.types.conversations.conversation import Conversation
from openai.types.responses.response import Response
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from openai.types.shared_params import Reasoning, ResponsesModel
from openai.types.vector_store import VectorStore
from playsound3 import playsound
from syntaxmod import wait_until
from typing_extensions import TypedDict

warnings.filterwarnings("ignore")


PropertySpec: TypeAlias = dict[str, str]
Properties: TypeAlias = dict[str, PropertySpec]
Parameters: TypeAlias = dict[str, str | Properties | list[str]]
FunctionSpec: TypeAlias = dict[str, str | Parameters]
ToolSpec: TypeAlias = dict[str, str | FunctionSpec]

Seconds: TypeAlias = int


VadAgressiveness: TypeAlias = Literal[1, 2, 3]


Number: TypeAlias = int | float


if TYPE_CHECKING:
    from .Images import Openai_Images


def preload_openai_stt():
    """Start a background process that pre-imports the speech-to-text module.

    Returns:
        subprocess.Popen: Handle to the loader process so callers can verify startup.

    Example:
        >>> loader = preload_openai_stt()
        >>> loader.poll() is None
        True

    Note:
        Call ``loader.terminate()`` once the warm-up process is no longer needed.
    """
    return subprocess.Popen(
        [sys.executable, "-c", "import openai_stt"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )


STT_LOADER = preload_openai_stt()


class Assistant:
    """High-level helper that orchestrates OpenAI chat, tools, vector stores, audio, and images.

    Example:
        >>> assistant = Assistant(api_key=\"sk-test\", model=\"gpt-4o-mini\")
        >>> assistant.chat(\"Ping!\")  # doctest: +ELLIPSIS
        '...'

    Note:
        The assistant reuses a shared speech-to-text loader so audio helpers start quickly.
        Function tools decorated with ``openai_function`` can also be registered globally via
        ``assistant.function_call_list``.
    """

    def __init__(
        self,
        api_key: str | None = None,
        model: ResponsesModel = "gpt-5-nano",
        system_prompt: str = "",
        default_conversation: Conversation | bool = True,
        temperature: float | None = None,
        reasoning_effort: Literal["minimal", "low", "medium", "high"] | None = None,
        summary_length: Literal["auto", "concise", "detailed"] | None = None,
        init_headers: Mapping[str, Any] | dict[str, Any] = {},
    ):
        """Initialise the assistant client and, optionally, a default conversation.

        Args:
            api_key: Explicit OpenAI API key. When omitted the ``OPENAI_API_KEY`` environment
                variable must be set.
            model: Default Responses API model identifier to use for `chat` requests.
            system_prompt: System instructions prepended to every conversation turn.
            default_conversation: Pass ``True`` to create a fresh server-side conversation,
                supply an existing `Conversation` object to reuse it, or set to ``False`` to
                defer conversation creation.
            temperature: Optional sampling temperature forwarded to the OpenAI API.
            reasoning_effort: Optional reasoning effort hint for models that support it.
            summary_length: Optional reasoning summary length hint for compatible models.

        Raises:
            ValueError: If neither ``api_key`` nor ``OPENAI_API_KEY`` is provided.

        Example:
            >>> assistant = Assistant(system_prompt="You are concise.")  # doctest: +SKIP
            >>> assistant.model
            'chatgpt-4o-latest'

        Note:
            When either ``reasoning_effort`` or ``summary_length`` is supplied the assistant
            constructs a reusable `Reasoning` payload that is automatically applied to every
            `chat` call.
        """
        resolved_key = api_key or getenv("OPENAI_API_KEY")
        if not resolved_key:
            raise ValueError("No API key provided.")

        self._api_key = str(resolved_key)
        self._model = model
        self._client = OpenAI(api_key=self._api_key, **init_headers)
        self._system_prompt = system_prompt
        self._temperature = temperature
        self._reasoning_effort = reasoning_effort
        self._summary_length = summary_length
        self._reasoning: Reasoning | None = None

        self._function_call_list: list[types.FunctionType] = []

        conversation: Conversation | None = None
        if default_conversation is True:
            conversation = self._client.conversations.create()
        elif isinstance(default_conversation, Conversation):
            conversation = default_conversation

        self._conversation = conversation
        self._conversation_id = self._conversation.id if self._conversation else None

        self._stt: Any = None
        self._refresh_reasoning()

    def _refresh_reasoning(self) -> None:
        """Rebuild the reusable Reasoning payload from the current configuration."""

        reasoning_kwargs: dict[str, Any] = {}
        if self._reasoning_effort:
            reasoning_kwargs["effort"] = self._reasoning_effort
        if self._summary_length:
            reasoning_kwargs["summary"] = self._summary_length
        self._reasoning = Reasoning(**reasoning_kwargs) if reasoning_kwargs else None

    def _convert_filepath_to_vector(
        self, list_of_files: list[str]
    ) -> tuple[VectorStore, VectorStore, VectorStores]:
        """Upload local files into a fresh vector store.

        Args:
            list_of_files: Absolute or relative file paths that will seed the store.

        Returns:
            tuple[VectorStore, VectorStore, VectorStores]: The created store summary,
            a retrieved store instance, and the vector store manager reference for
            follow-up operations.

        Raises:
            ValueError: If the provided file list is empty.
            FileNotFoundError: When any supplied path does not exist.

        Example:
            >>> assistant = Assistant(api_key=\"sk-test\")  # doctest: +SKIP
            >>> summary, retrieved, manager = assistant._convert_filepath_to_vector([\"docs/guide.md\"])  # doctest: +SKIP
            >>> summary.name  # doctest: +SKIP
            'vector_store'

        Note:
            The helper uploads synchronously; large files may take several seconds to index.
        """
        if not isinstance(list_of_files, list) or len(list_of_files) == 0:
            raise ValueError("list_of_files must be a non-empty list of file paths.")
        for filepath in list_of_files:
            if not os.path.exists(filepath):
                raise FileNotFoundError(f"File not found: {filepath}")

        vector_store_create = self._client.vector_stores.create(name="vector_store")
        vector_store = self._client.vector_stores.retrieve(vector_store_create.id)
        vector = self._client.vector_stores
        for filepath in list_of_files:
            with open(filepath, "rb") as f:
                self._client.vector_stores.files.upload_and_poll(
                    vector_store_id=vector_store_create.id, file=f
                )
        return vector_store_create, vector_store, vector

    def openai_function(self, func: types.FunctionType) -> types.FunctionType:
        """
        Decorator for OpenAI functions.

        Args:
            func (types.FunctionType): The function to decorate.

        Returns:
            types.FunctionType: The original function augmented with a ``schema`` attribute.

        Example:
            >>> assistant = Assistant(api_key=\"sk-test\")  # doctest: +SKIP
            >>> @assistant.openai_function  # doctest: +SKIP
            ... def greet(name: str) -> dict:  # doctest: +SKIP
            ...     \"\"\"Description:\\n        Make a friendly greeting.\\n        Args:\\n            name: Person to greet.\\n        \"\"\"  # doctest: +SKIP
            ...     return {\"message\": f\"Hello {name}!\"}  # doctest: +SKIP
            >>> greet.schema[\"name\"]  # doctest: +SKIP
            'greet'

        Note:
            The wrapped function receives the same call signature it declared; only metadata changes.
        """
        if not isinstance(func, types.FunctionType):
            raise TypeError("Expected a plain function (types.FunctionType)")

        doc = inspect.getdoc(func) or ""

        def extract_block(name: str) -> dict:
            """Parse a docstring section into a mapping of parameter names to descriptions.

            Args:
                name: Header label to search for (for example ``"Args"``).

            Returns:
                dict: Key/value mapping describing parameters defined in the block.

            Example:
                If the docstring contains::

                    Args:
                        city: The city to describe.

                then ``extract_block("Args")`` returns ``{"city": "The city to describe."}``.
            """
            pattern = re.compile(
                rf"{name}:\s*\n((?:\s+.+\n?)+?)(?=^[A-Z][A-Za-z_ ]*:\s*$|$)",
                re.MULTILINE,
            )
            match = pattern.search(doc)
            if not match:
                return {}
            lines = match.group(1).strip().splitlines()
            block_dict = {}
            for line in lines:
                if ":" not in line:
                    continue
                key, val = line.split(":", 1)
                block_dict[key.strip()] = val.strip()
            return block_dict

        def extract_description() -> str:
            """Return the free-form description block from the function docstring.

            Example:
                Given a section like::

                    Description:
                        Provide a short overview.

                the helper returns ``\"Provide a short overview.\"``.
            """
            pattern = re.compile(
                r"Description:\s*\n((?:\s+.+\n?)+?)(?=^[A-Z][A-Za-z_ ]*:\s*$|$)",
                re.MULTILINE,
            )
            match = pattern.search(doc)
            if not match:
                return ""
            return " ".join(line.strip() for line in match.group(1).splitlines())

        args = extract_block("Args")
        params = extract_block("Params")
        merged = {**args, **params}
        description = extract_description()

        sig = inspect.signature(func)
        properties = {}
        required = []

        for name, desc in merged.items():
            param = sig.parameters.get(name)
            required_flag = param.default is inspect._empty if param else True
            properties[name] = {
                "type": "string",  # you could infer more types if needed
                "description": desc,
            }
            if required_flag:
                required.append(name)

        doc = str(inspect.getdoc(func))
        schema = {
            "type": "function",
            "name": func.__name__,
            # type: ignore
            "description": description or doc.strip().split("\n")[0],
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        }

        func.schema = schema  # type: ignore
        return func  # type: ignore

    def _build_tool_map(
        self, tools: list[types.FunctionType]
    ) -> tuple[dict[str, types.FunctionType], list[dict[str, Any]]]:
        """Create a mapping of tool names to callables and collect their schemas."""

        tool_map: dict[str, types.FunctionType] = {}
        schemas: list[dict[str, Any]] = []

        for tool in tools:
            schema = getattr(tool, "schema", None)
            if not schema:
                warnings.warn(
                    f"Skipping tool {tool.__name__} because it lacks an OpenAI schema."
                )
                continue

            name = schema.get("name", tool.__name__)
            tool_map[name] = tool
            if schema not in schemas:
                schemas.append(schema)

        return tool_map, schemas

    def _prepare_function_tools(
        self, tools: list[types.FunctionType]
    ) -> tuple[list[dict[str, Any]], dict[str, types.FunctionType]]:
        """Auto-decorate tools if needed, then return ``(schemas, tool_map)``.

        Unlike ``_build_tool_map``, this method applies ``openai_function`` to any
        callable that does not already carry a ``schema`` attribute, so callers can
        pass plain functions without pre-decorating them.

        Args:
            tools: Callables to prepare — decorated or plain.

        Returns:
            tuple[list, dict]: A pair of ``(schemas, tool_map)`` where ``schemas``
            is the list ready to send to the API and ``tool_map`` maps names to
            callables for local execution.
        """
        decorated: list[types.FunctionType] = []
        for tool in tools:
            if not hasattr(tool, "schema"):
                tool = self.openai_function(tool)
            decorated.append(tool)
        tool_map, schemas = self._build_tool_map(decorated)
        return schemas, tool_map

    def _format_tool_result(self, result: Any) -> str:
        """Serialize tool results into a string payload for the API."""

        if isinstance(result, (dict, list)):
            try:
                return json.dumps(result)
            except TypeError:
                return str(result)
        return "" if result is None else str(result)

    def _invoke_tool_function(self, func: types.FunctionType, arguments: str) -> str:
        """Execute a registered tool with JSON encoded arguments."""

        parsed_arguments: Any
        if arguments:
            try:
                parsed_arguments = json.loads(arguments)
            except json.JSONDecodeError:
                parsed_arguments = {}
        else:
            parsed_arguments = {}

        try:
            if isinstance(parsed_arguments, dict):
                result = func(**parsed_arguments)
            elif isinstance(parsed_arguments, list):
                result = func(*parsed_arguments)
            else:
                result = func(parsed_arguments)
        except Exception as exc:  # pragma: no cover - surface tool errors
            raise RuntimeError(
                f"Error while executing tool '{func.__name__}': {exc}"
            ) from exc

        return self._format_tool_result(result)

    def _gather_function_calls(
        self, response: Response
    ) -> list[ResponseFunctionToolCall]:
        """Extract all function tool calls from an API response."""

        calls: list[ResponseFunctionToolCall] = []
        for item in getattr(response, "output", []) or []:
            if getattr(item, "type", None) == "function_call":
                calls.append(item)  # type: ignore[arg-type]
        return calls

    def _prepare_tool_outputs(
        self,
        tool_calls: list[ResponseFunctionToolCall],
        tool_map: dict[str, types.FunctionType],
    ) -> list[dict[str, Any]]:
        """Execute model requested tools and package outputs for the API."""

        outputs: list[dict[str, Any]] = []
        for call in tool_calls:
            func = tool_map.get(call.name)
            if not func:
                warnings.warn(
                    f"No tool registered for function call '{call.name}'. Skipping."
                )
                continue

            output = self._invoke_tool_function(func, call.arguments)
            outputs.append(
                {
                    "type": "function_call_output",
                    "call_id": call.call_id,
                    "output": output,
                }
            )

        return outputs

    def _resolve_response_with_tools(
        self,
        params: dict[str, Any],
        tool_map: dict[str, types.FunctionType],
    ) -> Response:
        """Call the Responses API and automatically fulfil tool invocations."""

        request_params = dict(params)
        request_params.pop("stream", None)
        request_params.setdefault("tools", list(params.get("tools", [])))
        history_input = list(request_params.get("input", []))
        conversation_id: str | None = (
            request_params.get("conversation")
            if isinstance(request_params.get("conversation"), str)
            else None
        )

        response = self._client.responses.create(**request_params)

        while tool_map:
            tool_calls = self._gather_function_calls(response)
            if not tool_calls:
                break

            tool_outputs = self._prepare_tool_outputs(tool_calls, tool_map)
            if not tool_outputs:
                break

            conversation_id = (
                getattr(response.conversation, "id", None) or conversation_id
            )

            if conversation_id:
                request_params["conversation"] = conversation_id
                request_params["input"] = tool_outputs
            else:
                history_input.extend(tool_outputs)
                request_params["input"] = history_input

            response = self._client.responses.create(**request_params)

        return response

    def _function_call_stream(
        self, params: dict[str, Any], tool_map: dict[str, types.FunctionType]
    ) -> Generator[str, Any, None]:
        """Yield streamed text while resolving tool calls between iterations."""

        request_params = dict(params)
        request_params.pop("stream", None)
        request_params.setdefault("tools", list(params.get("tools", [])))
        history_input = list(request_params.get("input", []))
        conversation_id: str | None = (
            request_params.get("conversation")
            if isinstance(request_params.get("conversation"), str)
            else None
        )

        while True:
            if not conversation_id:
                request_params["input"] = history_input

            with self._client.responses.stream(**request_params) as streamer:
                for event in streamer:
                    if event.type == "response.output_text.delta":
                        yield event.delta

                response = streamer.get_final_response()

            tool_calls = self._gather_function_calls(response)
            if not tool_calls or not tool_map:
                yield "done"
                break

            tool_outputs = self._prepare_tool_outputs(tool_calls, tool_map)
            if not tool_outputs:
                yield "done"
                break

            conversation_id = (
                getattr(response.conversation, "id", None) or conversation_id
            )

            if conversation_id:
                request_params["conversation"] = conversation_id
                request_params["input"] = tool_outputs
            else:
                history_input.extend(tool_outputs)
                request_params["input"] = history_input

    def _text_stream_generator(self, params_for_response):
        """Yield response text deltas while the streaming API is producing output.

        Args:
            params_for_response: Keyword arguments that will be forwarded to
                `client.responses.stream`.

        Yields:
            str: Individual text fragments or the sentinel string ``"done"``.

        Example:
            >>> assistant = Assistant(api_key=\"sk-test\")  # doctest: +SKIP
            >>> stream = assistant._text_stream_generator({\"input\": \"Hello\"})  # doctest: +SKIP
            >>> next(stream)  # doctest: +SKIP
            'Hel'

        Note:
            This helper is primarily used internally when ``text_stream=True`` is passed to ``chat``.
        """
        with self._client.responses.stream(**params_for_response) as streamer:
            for event in streamer:
                if event.type == "response.output_text.delta":
                    yield event.delta
                elif event.type == "response.completed":
                    yield "done"

    def chat(
        self,
        input: str,
        conv_id: str | Conversation | None | bool = True,
        images: Sequence["Openai_Images"] | None = None,
        max_output_tokens: int | None = None,
        store: bool = False,
        web_search: bool = False,
        code_interpreter: bool = False,
        file_search: Sequence[str] | None = None,
        file_search_max_searches: int | None = None,
        mcp_urls: Sequence[str] | None = None,
        tools_required: Literal["none", "auto", "required"] = "auto",
        custom_tools: Sequence[types.FunctionType] | None = None,
        return_full_response: bool = False,
        valid_json: Mapping[str, Any] | None = None,
        stream: bool = False,
        text_stream: bool = False,
    ) -> str | Generator[str, Any, None] | Response:  # type: ignore
        """Send a chat request, optionally enabling tools, retrieval, or streaming output.

        Args:
            input: User prompt text to submit to the Responses API.
            conv_id: Conversation reference. Use ``True`` to reuse the assistant's default
                conversation, supply a conversation ID or `Conversation` instance, or set to
                ``False``/``None`` to start a stateless exchange.
            images: Optional sequence of `Openai_Images` helpers whose payloads will be
                attached to the request.
            max_output_tokens: Soft response cap forwarded to the OpenAI API.
            store: Persist the response to OpenAI's conversation store when ``True``.
            web_search: Include the web search tool in the toolset.
            code_interpreter: Include the code interpreter tool in the toolset.
            file_search: Iterable of local file paths that should be uploaded and searched
                against for retrieval-augmented responses.
            file_search_max_searches: Optional maximum search passes for the file-search tool.
            mcp_urls: Optional sequence of MCP server URLs to expose to the model as MCP tools.
            tools_required: Controls the OpenAI tool choice policy. Use ``"required"`` to force
                tool execution or ``"none"`` to disable it entirely.
            custom_tools: Sequence of callables decorated via `Assistant.openai_function` whose
                schemas will be advertised to the model.
            return_full_response: When ``True`` return the `Response` object instead of just text.
            valid_json: Optional mapping describing the JSON schema the model should follow. The
                prompt is augmented with instructions to favour that structure.
            stream: Forwarded directly to the OpenAI API to request server streaming.
            text_stream: When ``True`` yield deltas from the Responses API instead of waiting for
                completion. Tool results are still resolved between iterations.

        Returns:
            `str` when the call completes normally, a streaming generator when ``text_stream``
            is enabled, or the raw `Response` object when ``return_full_response`` (or ``stream``)
            is requested.

        Example:
            >>> assistant = Assistant(api_key="sk-test")  # doctest: +SKIP
            >>> @assistant.openai_function  # doctest: +SKIP
            ... def describe_city(city: str) -> dict:  # doctest: +SKIP
            ...     \"\"\"Args:\\n        city: Target city.\"\"\"  # doctest: +SKIP
            ...     return {"fact": f"{city} is lively."}  # doctest: +SKIP
            >>> assistant.chat("Tell me about Paris", custom_tools=[describe_city])  # doctest: +SKIP
            'Paris ...'

        Note:
            `custom_tools` and `self._function_call_list` are merged, deduplicated by schema name,
            and automatically executed until every tool call is satisfied.
        """

        conversation_ref: str | None
        if conv_id is True:
            # ensure conversation exists
            if not getattr(self, "_conversation", None):
                self._conversation = self._client.conversations.create()
            conversation_ref = getattr(self._conversation, "id", None)
        elif isinstance(conv_id, Conversation):
            conversation_ref = getattr(conv_id, "id", None)
        elif conv_id in (False, None):
            conversation_ref = None
        else:
            conversation_ref = str(conv_id)

        message_text = input
        if valid_json:
            json_hint = json.dumps(valid_json)
            message_text = (
                f"{input}\nRESPOND ONLY IN VALID JSON FORMAT LIKE THIS: {json_hint}"
            )

        user_content: list[dict[str, Any]] = [
            {
                "type": "input_text",
                "text": message_text,
            }
        ]

        if images:
            for image in images:
                payload_key = "file_id" if image.type == "filepath" else "image_url"
                payload_value = (
                    image.image[2]
                    if image.type != "Base64"
                    else f"data:image/{image.image[2]}; base64, {image.image[0]}"
                )
                user_content.append({"type": "input_image", payload_key: payload_value})

        params_for_response: dict[str, Any] = {
            "input": [
                {
                    "role": "user",
                    "content": user_content,
                }
            ],
            "instructions": self._system_prompt or None,
            "conversation": conversation_ref,
            "max_output_tokens": max_output_tokens,
            "store": store,
            "model": self._model,
            "reasoning": self._reasoning,
            "tools": [],
            "stream": stream,
        }

        if web_search:
            params_for_response["tools"].append({"type": "web_search"})

        if code_interpreter:
            params_for_response["tools"].append(
                {"type": "code_interpreter", "container": {"type": "auto"}}
            )

        vector_bundle: tuple[VectorStore, VectorStore, VectorStores] | None = None
        if file_search:
            vector_bundle = self._convert_filepath_to_vector(list(file_search))
            params_for_response["tools"].append(
                {
                    "type": "file_search",
                    "vector_store_ids": vector_bundle[1].id,
                    **(
                        {}
                        if file_search_max_searches is None
                        else {"max_searches": file_search_max_searches}
                    ),
                }
            )

        if mcp_urls:
            for index, url in enumerate(mcp_urls, start=1):
                if not url:
                    continue
                params_for_response["tools"].append(
                    {
                        "type": "mcp",
                        "server_url": str(url),
                        "server_label": f"mcp_server_{index}",
                    }
                )

        params_for_response = {
            key: value
            for key, value in params_for_response.items()
            if value is not None and value is not False
        }

        if tools_required != "auto":
            params_for_response["tool_choice"] = tools_required

        builtin_tools = list(self._function_call_list)
        user_tools = list(custom_tools) if custom_tools else []
        combined_tools = builtin_tools + user_tools
        if combined_tools:
            tool_schemas, tool_map = self._prepare_function_tools(combined_tools)
        else:
            tool_map, tool_schemas = {}, []

        if tool_schemas:
            params_for_response.setdefault("tools", []).extend(tool_schemas)

        resp: Response | None = None
        stream_gen: Generator[str, Any, None] | None = None
        returns_flag = True

        request_params = dict(params_for_response)
        if "tools" in request_params:
            request_params["tools"] = list(request_params["tools"])

        if text_stream:
            stream_gen = self._function_call_stream(request_params, tool_map)
        else:
            resp = self._resolve_response_with_tools(request_params, tool_map)

        if text_stream:
            return (
                stream_gen
                if stream_gen is not None
                else self._text_stream_generator(params_for_response)
            )

        if store and returns_flag and resp is not None:
            self._conversation = resp.conversation

        if vector_bundle:
            vector_bundle[2].delete(vector_bundle[0].id)

        if returns_flag:
            if return_full_response or stream:
                return resp  # type: ignore
            return resp.output_text if resp is not None else ""

            return ""

    def create_conversation(self, return_id_only: bool = False) -> Conversation | str:
        """
        Create a conversation.

        Args:
            return_id_only (bool, optional): If True, return only the conversation ID, by default False.

        Returns:
            Conversation | str: The full conversation object or just its ID.

        Example:
            >>> assistant = Assistant(api_key=\"sk-test\")  # doctest: +SKIP
            >>> convo_id = assistant.create_conversation(return_id_only=True)  # doctest: +SKIP
            >>> convo_id.startswith(\"conv_\")  # doctest: +SKIP
            True

        Note:
            Reuse the returned conversation ID to continue multi-turn exchanges.
        """

        conversation = self._client.conversations.create()
        if return_id_only:
            return conversation.id
        return conversation

    def image_generation(
        self,
        prompt: str,
        model: Literal["gpt-image-1", "dall-e-2", "dall-e-3"] = "gpt-image-1",
        background: Literal["transparent", "opaque", "auto"] | None = None,
        output_format: Literal["webp", "png", "jpeg"] = "png",
        output_compression: int | None = None,
        quality: (
            Literal["standard", "hd", "low", "medium", "high", "auto"] | None
        ) = None,
        size: (
            Literal[
                "auto",
                "1024x1024",
                "1536x1024",
                "1024x1536",
                "256x256",
                "512x512",
                "1792x1024",
                "1024x1792",
            ]
            | None
        ) = None,
        n: int = 1,
        moderation: Literal["auto", "low"] | None = None,
        style: Literal["vivid", "natural"] | None = None,
        return_base64: bool = False,
        make_file: bool = False,
        save_to_file: str = "",
    ):
        """**prompt**
        A text description of the desired image(s). The maximum length is 32000 characters for `gpt-image-1`, 1000 characters for `dall-e-2` and 4000 characters for `dall-e-3`.

        **background**
        Allows to set transparency for the background of the generated image(s). This parameter is only supported for `gpt-image-1`. Must be one of `transparent`, `opaque` or `auto` (default value). When `auto` is used, the model will automatically determine the best background for the image.

        If `transparent`, the output format needs to support transparency, so it should be set to either `png` (default value) or `webp`.

        **model**
        The model to use for image generation. One of `dall-e-2`, `dall-e-3`, or `gpt-image-1`. Defaults to `dall-e-2` unless a parameter specific to `gpt-image-1` is used.

        **moderation**
        Control the content-moderation level for images generated by `gpt-image-1`. Must be either `low` for less restrictive filtering or `auto` (default value).

        **n**
        The number of images to generate. Must be between 1 and 10. For `dall-e-3`, only `n=1` is supported.

        **output_compression**
        The compression level (0-100%) for the generated images. This parameter is only supported for `gpt-image-1` with the `webp` or `jpeg` output formats, and defaults to 100.

        **output_format**
        The format in which the generated images are returned. This parameter is only supported for `gpt-image-1`. Must be one of `png`, `jpeg`, or `webp`.

        **quality**
        The quality of the image that will be generated.* `auto` (default value) will automatically select the best quality for the given model.

        * `high`, `medium` and `low` are supported for `gpt-image-1`.
        * `hd` and `standard` are supported for `dall-e-3`.
        * `standard` is the only option for `dall-e-2`.

        **size**
        The size of the generated images. Must be one of `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto` (default value) for `gpt-image-1`, one of `256x256`, `512x512`, or `1024x1024` for `dall-e-2`, and one of `1024x1024`, `1792x1024`, or `1024x1792` for `dall-e-3`.

        **style**
        The style of the generated images. This parameter is only supported for `dall-e-3`. Must be one of `vivid` or `natural`. Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images.

        **return_base64**
        When ``True`` the base64 payload is returned to the caller instead of writing to disk.

        **make_file**
        Set to ``True`` to persist the generated image locally using ``save_to_file``.

        **save_to_file**
        File path used when `make_file` is enabled. The helper appends the correct extension automatically.

        Example:
            >>> assistant = Assistant(api_key="sk-test")  # doctest: +SKIP
            >>> image_b64 = assistant.image_generation("Neon city skyline", n=1, return_base64=True)  # doctest: +SKIP
            >>> isinstance(image_b64, str)  # doctest: +SKIP
            True

        Note:
            When ``make_file=True``, provide ``save_to_file`` with a writable path to persist the image.
        """
        params = {
            "model": model,
            "prompt": prompt,
            "background": background,
            "output_format": output_format if model == "gpt-image-1" else None,
            "output_compression": output_compression,
            "quality": quality,
            "size": size,
            "n": n,
            "moderation": moderation,
            "style": style,
            "response_format": "b64_json" if model != "gpt-image-1" else None,
        }

        clean_params = {k: v for k, v in params.items() if v is not None}

        try:
            img = self._client.images.generate(**clean_params)

        except Exception as e:
            raise e

        if return_base64 and not make_file:
            return img.data[0].b64_json
        elif make_file and not return_base64:
            image_data = img.data[0].b64_json
            with open(save_to_file, "wb") as f:
                f.write(base64.b64decode(image_data))
        else:
            image_data = img.data[0].b64_json
            if not save_to_file.endswith("." + output_format):
                name = save_to_file + "." + output_format
            else:
                name = save_to_file
            with open(name, "wb") as f:
                f.write(base64.b64decode(image_data))

            return img.data[0].b64_json

    def update_assistant(
        self,
        what_to_change: Literal[
            "model",
            "system_prompt",
            "temperature",
            "reasoning_effort",
            "summary_length",
            "function_call_list",
        ],
        new_value,
    ):
        """Update a single configuration attribute on the assistant instance.

        Args:
            what_to_change: The configuration field to replace.
            new_value: Value assigned to the selected field.

        Raises:
            ValueError: If ``what_to_change`` is not one of the supported keys.

        Example:
            >>> assistant = Assistant(api_key="sk-test")  # doctest: +SKIP
            >>> assistant.update_assistant("system_prompt", "Be concise.")  # doctest: +SKIP
            >>> assistant.system_prompt  # doctest: +SKIP
            'Be concise.'

        Note:
            Updating ``reasoning_effort`` or ``summary_length`` refreshes the cached
            `Reasoning` helper automatically. When assigning ``function_call_list`` provide
            callables decorated via `Assistant.openai_function`.
        """

        field_map = {
            "model": "model",
            "system_prompt": "system_prompt",
            "temperature": "temperature",
            "reasoning_effort": "reasoning_effort",
            "summary_length": "summary_length",
            "function_call_list": "function_call_list",
        }

        try:
            attribute_name = field_map[what_to_change]
        except KeyError as exc:
            raise ValueError("Invalid parameter to change") from exc

        setattr(self, attribute_name, new_value)

        if attribute_name in {"reasoning_effort", "summary_length"}:
            self._refresh_reasoning()

    def text_to_speech(
        self,
        input: str,
        model: Literal["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] = "tts-1",
        voice: (
            str
            | Literal[
                "alloy",
                "ash",
                "ballad",
                "coral",
                "echo",
                "sage",
                "shimmer",
                "verse",
                "marin",
                "cedar",
            ]
        ) = "alloy",
        instructions: str = "NOT_GIVEN",
        response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "wav",
        speed: float = 1,
        play: bool = True,
        play_in_background: bool = False,
        save_to_file_path: str | None = None,
    ):
        """Convert text into speech audio using OpenAI's text-to-speech API.

        Args:
            input: Text content to synthesise.
            model: Text-to-speech model identifier.
            voice: Voice preset or literal name supported by the selected model.
            instructions: Optional style instructions forwarded to the API.
            response_format: Output container format to request.
            speed: Playback rate multiplier accepted by the API.
            play: When ``True`` immediately play the generated audio.
            play_in_background: Set to ``True`` to play audio asynchronously.
            save_to_file_path: Destination path for persisting the audio artefact.

        Returns:
            None. Audio data is saved to disk and/or played as a side effect.

        Example:
            >>> assistant = Assistant(api_key="sk-test")  # doctest: +SKIP
            >>> assistant.text_to_speech("Daily stand-up is in 5 minutes.", voice="sage", save_to_file_path="standup.wav")  # doctest: +SKIP

        Note:
            Non-``wav`` formats are written successfully but cannot be played inline by the
            helper; set ``play=False`` when requesting alternative formats.
        """
        params = {
            "input": input,
            "model": model,
            "voice": voice,
            "instructions": instructions,
            "response_format": response_format,
            "speed": speed,
        }

        respo = self._client.audio.speech.create(**params)

        if save_to_file_path:
            respo.write_to_file(str(save_to_file_path))
            if play:
                sound = playsound(str(save_to_file_path), block=play_in_background)
                while sound.is_alive():
                    pass

        else:
            if play:
                with tempfile.NamedTemporaryFile(
                    delete=False, suffix="." + response_format, delete_on_close=False
                ) as f:
                    respo.write_to_file(f.name)
                    f.flush()
                    f.close()
                    sound = playsound(f.name, block=play_in_background)
                    while sound.is_alive():
                        pass
                    os.remove(f.name)

        if response_format != "wav" and play:
            print("Only wav format is supported for playing audio")

    def full_text_to_speech(
        self,
        input: str,
        conv_id: str | Conversation | bool | None = True,
        max_output_tokens: int | None = None,
        store: bool | None = False,
        web_search: bool | None = None,
        code_interpreter: bool | None = None,
        file_search: Sequence[str] | None = None,
        custom_tools: Sequence[types.FunctionType] | None = None,
        tools_required: Literal["none", "auto", "required"] = "auto",
        model: Literal["tts-1", "tts-1-hd", "gpt-4o-mini-tts"] = "tts-1",
        voice: (
            str
            | Literal[
                "alloy",
                "ash",
                "ballad",
                "coral",
                "echo",
                "sage",
                "shimmer",
                "verse",
                "marin",
                "cedar",
            ]
        ) = "alloy",
        instructions: str = "NOT_GIVEN",
        response_format: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "wav",
        speed: float = 1,
        play: bool = True,
        print_response: bool = False,
        save_to_file_path: str | None = None,
    ) -> str:
        """Ask the model a question and immediately voice the reply.

        Args:
            input: User prompt provided to `chat` before audio playback.
            conv_id: Conversation reference mirroring the `chat` parameter of the same name.
            max_output_tokens: Optional cap applied to the intermediate chat response.
            store: Persist the intermediate chat result to the conversation store.
            web_search: Enable the web search tool during the chat request.
            code_interpreter: Enable the code interpreter tool during the chat request.
            file_search: Iterable of file paths to ground the chat response.
            custom_tools: Additional tool callables (decorated via `openai_function`) available to the chat phase.
            tools_required: Passed through to the underlying `chat` call.
            model: Text-to-speech model used to synthesise audio.
            voice: Voice preset for the speech model.
            instructions: Optional style guidance for the speech synthesis.
            response_format: Audio container requested from the speech API.
            speed: Playback rate multiplier.
            play: Immediately play the generated audio.
            print_response: Echo the intermediate chat result before speaking.
            save_to_file_path: Persist the audio file when provided; otherwise a temporary file is used.

        Returns:
            str: The text generated by the chat phase (the same content that is spoken aloud).

        Example:
            >>> assistant = Assistant(api_key="sk-test")  # doctest: +SKIP
            >>> assistant.full_text_to_speech("Give me a 1 sentence update.", voice="verse", play=False)  # doctest: +SKIP
            'Project launch rehearsals are on track for tomorrow.'

        Note:
            All keyword arguments not listed are forwarded directly to `Assistant.chat`. Tool
            outputs are resolved before audio playback begins.
        """
        param = {
            "input": input,
            "conv_id": conv_id,
            "max_output_tokens": max_output_tokens,
            "store": store,
            "web_search": web_search,
            "code_interpreter": code_interpreter,
            "file_search": list(file_search) if file_search else None,
            "custom_tools": list(custom_tools) if custom_tools else None,
            "tools_required": tools_required,
        }

        resp = self.chat(**param)

        say_params = {
            "model": model,
            "voice": voice,
            "instructions": instructions,
            "response_format": response_format,
            "speed": speed,
            "play": play,
            "save_to_file_path": save_to_file_path,
            "input": resp,
        }

        if print_response:
            print(resp)
        self.text_to_speech(**say_params)

        return resp  # type: ignore

    def speech_to_text(
        self,
        mode: Literal["vad", "keyboard"] | Seconds = "vad",
        model: Literal[
            "tiny.en",
            "tiny",
            "base.en",
            "base",
            "small.en",
            "small",
            "medium.en",
            "medium",
            "large-v1",
            "large-v2",
            "large-v3",
            "large",
            "large-v3-turbo",
            "turbo",
            "gpt-4o-transcribe",
            "gpt-4o-mini-transcribe",
        ] = "base",
        aggressive: VadAgressiveness = 2,
        chunk_duration_ms: int = 30,
        log_directions: bool = False,
        key: str = "space",
    ):
        """Capture audio input and run it through the cached speech-to-text client.

        Args:
            mode: Recording strategy; ``"vad"`` records until silence, ``"keyboard"``
                toggles with a hotkey, or a numeric value records for that many seconds.
            model: Whisper or OpenAI speech model identifier.
            aggressive: Voice activity detection aggressiveness when using VAD.
            chunk_duration_ms: Frame size for VAD processing in milliseconds.
            log_directions: Whether to print instructions to the console.
            key: Keyboard key that toggles recording when ``mode="keyboard"``.

        Returns:
            str: The recognized transcript.

        Example:
            >>> assistant = Assistant(api_key=\"sk-test\")  # doctest: +SKIP
            >>> transcript = assistant.speech_to_text(mode=\"vad\", model=\"base.en\")  # doctest: +SKIP
            >>> isinstance(transcript, str)  # doctest: +SKIP
            True

        Note:
            The first invocation warms up the speech model and can take noticeably longer.
        """
        wait_until(not STT_LOADER.poll() is None)
        import openai_stt as stt

        if self._stt == None:
            stt_model = stt.STT(
                model=model, aggressive=aggressive, chunk_duration_ms=chunk_duration_ms
            )
            self._stt = stt_model

        else:
            stt_model = self._stt

        if mode == "keyboard":
            result = stt_model.record_with_keyboard(log=log_directions, key=key)
        elif mode == "vad":
            result = stt_model.record_with_vad(log=log_directions)

        elif isinstance(mode, Seconds):
            result = stt_model.record_for_seconds(mode)

        return result

    class __mass_update_helper(TypedDict, total=False):
        """TypedDict describing the accepted keyword arguments for `mass_update`.

        Example:
            >>> from typing import get_type_hints
            >>> hints = get_type_hints(Assistant.__mass_update_helper)
            >>> sorted(hints.keys())
            ['function_call_list', 'model', 'reasoning_effort', 'summary_length', 'system_prompt', 'temperature']

        Note:
            The helper is intended for type checkers and IDEs; you rarely need to instantiate it directly.
        """

        model: ResponsesModel
        system_prompt: str
        temperature: float
        reasoning_effort: Literal["minimal", "low", "medium", "high"]
        summary_length: Literal["auto", "concise", "detailed"]
        function_call_list: list[types.FunctionType]

    def mass_update(self, **__mass_update_helper: Unpack[__mass_update_helper]):
        """Bulk assign configuration attributes using keyword arguments.

        Args:
            **__mass_update_helper: Arbitrary subset of Assistant configuration
                fields such as ``model`` or ``temperature``.

        Example:
            >>> assistant = Assistant(api_key=\"sk-test\")  # doctest: +SKIP
            >>> assistant.mass_update(model=\"gpt-4o-mini\", temperature=0.1)  # doctest: +SKIP
            >>> assistant.temperature  # doctest: +SKIP
            0.1

        Note:
            Any provided keys are applied directly to instance attributes without additional validation.
            Updates to ``reasoning_effort`` or ``summary_length`` automatically rebuild the cached reasoning payload.
        """
        for key, value in __mass_update_helper.items():
            setattr(self, key, value)
        if {"reasoning_effort", "summary_length"} & set(__mass_update_helper):
            self._refresh_reasoning()


if __name__ == "__main__":
    bob: Assistant = Assistant(
        api_key=None,
        model="chatgpt-4o-latest",
        system_prompt="You are a helpful assistant.",
    )

    print(bob.speech_to_text(mode="vad", model="base.en", log_directions=True))
