#' @include provider.R
#' @include provider-openai-compatible.R
#' @include content.R
#' @include turns.R
#' @include tools-def.R
NULL

#' Chat with an OpenAI model
#'
#' @description
#' `r support_badge("official")`
#'
#' This is the main interface to [OpenAI](https://openai.com/)'s models,
#' using the **responses API**. You can use this to access OpenAI's latest
#' models and features like image generation and web search. If you need to use
#' an OpenAI-compatible API from another provider, or the **chat completions**
#' API with OpenAI,use [chat_openai_compatible()] instead.
#'
#' Note that a ChatGPT Plus membership does not grant access to the API.
#' You will need to sign up for a developer account (and pay for it) at the
#' [developer platform](https://platform.openai.com).
#'
#' @param system_prompt A system prompt to set the behavior of the assistant.
#' @param base_url The base URL to the API endpoint.
#' @param api_key `r lifecycle::badge("deprecated")` Use `credentials` instead.
#' @param credentials `r api_key_param("OPENAI_API_KEY")`
#' @param model `r param_model("gpt-5.6-terra", "openai")`
#' @param params Common model parameters, usually created by [params()].
#' @param api_args Named list of arbitrary extra arguments appended to the body
#'   of every chat API call. Combined with the body object generated by ellmer
#'   with [modifyList()].
#' @param api_headers Named character vector of arbitrary extra headers appended
#'   to every chat API call.
#' @param service_tier Request a specific service tier. There are four options:
#'   * `"auto"` (default): uses the service tier configured in Project settings.
#'   * `"default"`: standard pricing and performance.
#'   * `"flex"`: slower and cheaper.
#'   * `"priority"`: faster and more expensive.
#' @param echo One of the following options:
#'   * `none`: don't emit any output (default when running in a function).
#'   * `output`: echo text and tool-calling output as it streams in (default
#'     when running at the console).
#'   * `all`: echo all input and output.
#'
#'   Note this only affects the `chat()` method.
#' @family chatbots
#' @export
#' @returns A [Chat] object.
#' @examples
#' \dontshow{ellmer:::vcr_example_start("chat_openai")}
#' chat <- chat_openai()
#' chat$chat("
#'   What is the difference between a tibble and a data frame?
#'   Answer with a bulleted list
#' ")
#'
#' chat$chat("Tell me three funny jokes about statisticians")
#' \dontshow{ellmer:::vcr_example_end()}
chat_openai <- function(
  system_prompt = NULL,
  base_url = "https://api.openai.com/v1",
  api_key = NULL,
  credentials = NULL,
  model = NULL,
  params = NULL,
  api_args = list(),
  api_headers = character(),
  service_tier = c("auto", "default", "flex", "priority"),
  echo = c("none", "output", "all")
) {
  model <- set_default(model, "gpt-5.6-terra")
  echo <- check_echo(echo)
  service_tier <- arg_match(service_tier)

  credentials <- as_credentials(
    "chat_openai",
    \() openai_key(),
    credentials = credentials,
    api_key = api_key
  )

  provider <- ProviderOpenAI(
    name = "OpenAI",
    base_url = base_url,
    model = model,
    params = params %||% params(),
    extra_args = api_args,
    extra_headers = api_headers,
    credentials = credentials,
    service_tier = service_tier
  )
  Chat$new(provider = provider, system_prompt = system_prompt, echo = echo)
}

#' @rdname chat_openai
#' @export
models_openai <- function(
  base_url = "https://api.openai.com/v1",
  api_key = NULL,
  credentials = NULL
) {
  credentials <- as_credentials(
    "models_openai",
    \() openai_key(),
    credentials = credentials,
    api_key = api_key
  )

  provider <- ProviderOpenAICompatible(
    name = "OpenAI",
    model = "",
    base_url = base_url,
    credentials = credentials
  )

  models_list(provider)
}

chat_openai_test <- function(
  system_prompt = "Be terse. Respond with only the answer.",
  ...,
  model = "gpt-5.4-nano",
  params = NULL,
  echo = "none"
) {
  params <- params %||% params()
  params$temperature <- params$temperature %||% 0

  chat_openai(
    system_prompt = system_prompt,
    model = model,
    params = params,
    ...,
    echo = echo
  )
}

ProviderOpenAI <- new_class(
  "ProviderOpenAI",
  parent = ProviderOpenAICompatible,
  properties = list(
    service_tier = class_character
  )
)


# Chat endpoint ----------------------------------------------------------------

method(chat_path, ProviderOpenAI) <- function(provider) {
  "/responses"
}

# https://platform.openai.com/docs/api-reference/responses
method(chat_body, ProviderOpenAI) <- function(
  provider,
  stream = TRUE,
  turns = list(),
  tools = list(),
  type = NULL
) {
  input <- compact(unlist(as_json(provider, turns), recursive = FALSE))
  tools <- chat_body_tools(provider, tools)

  if (!is.null(type)) {
    # https://platform.openai.com/docs/api-reference/responses/create#responses-create-text
    text <- list(
      format = list(
        type = "json_schema",
        name = "structured_data",
        schema = as_json(provider, type),
        strict = TRUE
      )
    )
  } else {
    text <- NULL
  }

  # https://platform.openai.com/docs/api-reference/responses/create#responses-create-include
  params <- chat_params(provider, provider@params)

  if (has_name(params, "reasoning_effort")) {
    reasoning <- list(
      effort = params$reasoning_effort,
      summary = "auto"
    )
    params$reasoning_effort <- NULL
  } else {
    reasoning <- NULL
  }

  include <- c(
    if (isTRUE(params$log_probs)) "message.output_text.logprobs",
    if (is_openai_reasoning(provider@model)) "reasoning.encrypted_content"
  )
  params$log_probs <- NULL

  compact(list2(
    input = input,
    include = as.list(include),
    model = provider@model,
    !!!params,
    stream = stream,
    tools = tools,
    text = text,
    reasoning = reasoning,
    store = FALSE,
    service_tier = provider@service_tier
  ))
}


method(chat_params, ProviderOpenAI) <- function(provider, params) {
  standardise_params(
    params,
    c(
      temperature = "temperature",
      top_p = "top_p",
      frequency_penalty = "frequency_penalty",
      max_output_tokens = "max_tokens",
      log_probs = "log_probs",
      top_logprobs = "top_k",
      reasoning_effort = "reasoning_effort"
    )
  )
}

# OpenAI -> ellmer --------------------------------------------------------------

method(stream_content, ProviderOpenAI) <- function(provider, event) {
  if (event$type == "response.output_text.delta") {
    # https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text/delta
    if (is.null(event$delta)) {
      return(NULL)
    }
    ContentText(event$delta)
  } else if (event$type == "response.reasoning_summary_text.delta") {
    # https://platform.openai.com/docs/api-reference/responses-streaming/response/reasoning_summary_text/delta
    ContentThinking(event$delta)
  } else if (event$type == "response.reasoning_summary_text.done") {
    # https://platform.openai.com/docs/api-reference/responses-streaming/response/reasoning_summary_text/done
    NULL
  }
}
method(stream_merge_chunks, ProviderOpenAI) <- function(
  provider,
  result,
  chunk
) {
  if (chunk$type %in% c("response.completed", "response.incomplete")) {
    chunk$response
  } else if (chunk$type == "response.failed") {
    # https://platform.openai.com/docs/api-reference/responses-streaming/response/failed
    error <- chunk$response$error
    cli::cli_abort(c("Request failed ({error$code})", "{error$message}"))
  } else if (chunk$type == "error") {
    # https://platform.openai.com/docs/api-reference/responses-streaming/error
    error <- chunk$error
    cli::cli_abort(c("Request errored ({error$type})", "{error$message}"))
  }
}

method(value_tokens, ProviderOpenAI) <- function(provider, json) {
  usage <- json$usage
  cached_tokens <- usage$input_tokens_details$cached_tokens %||% 0

  tokens(
    input = (usage$input_tokens %||% 0) - cached_tokens,
    output = usage$output_tokens %||% 0,
    cached_input = cached_tokens
  )
}

# https://platform.openai.com/docs/api-reference/responses/get
method(value_finish_reason, ProviderOpenAI) <- function(provider, result) {
  status <- result$status
  if (is.null(status)) {
    return(NA_character_)
  }
  if (identical(status, "completed")) {
    return("success")
  }
  if (!identical(status, "incomplete")) {
    return(I(status))
  }

  reason <- result$incomplete_details$reason %||% status
  switch(
    reason,
    max_output_tokens = "max_tokens",
    content_filter = "content_filter",
    I(reason)
  )
}

method(value_turn, ProviderOpenAI) <- function(
  provider,
  result,
  has_type = FALSE
) {
  contents <- lapply(result$output, function(output) {
    if (output$type == "message") {
      if (has_type) {
        ContentJson(jsonlite::parse_json(output$content[[1]]$text))
      } else {
        ContentText(output$content[[1]]$text)
      }
    } else if (output$type == "function_call") {
      arguments <- jsonlite::parse_json(output$arguments)
      ContentToolRequest(output$id, output$name, arguments)
    } else if (output$type == "reasoning") {
      thinking <- paste0(map_chr(output$summary, "[[", "text"), collapse = "")
      ContentThinking(thinking = thinking, extra = output)
    } else if (output$type == "image_generation_call") {
      mime_type <- switch(
        output$output_format,
        png = "image/png",
        jpeg = "image/jpeg",
        webp = "image/webp",
        "unknown"
      )
      ContentImageInline(mime_type, output$result)
    } else if (output$type == "web_search_call") {
      # https://platform.openai.com/docs/guides/tools-web-search#output-and-citations
      first_query <- if (length(output$action$queries)) {
        output$action$queries[[1]]
      }
      query <- output$action$query %||%
        first_query %||%
        output$action$url %||%
        "web search"
      ContentToolRequestSearch(query = query, json = output)
    } else {
      cli::cli_abort(
        "Unknown content type {.str {output$type}}.",
        .internal = TRUE
      )
    }
  })

  tokens <- value_tokens(provider, result)
  variant <- result$service_tier %||% "default"
  cost <- get_token_cost(provider, tokens, variant = variant)
  AssistantTurn(
    contents = contents,
    json = result,
    tokens = unlist(tokens),
    cost = cost,
    finish_reason = value_finish_reason(provider, result)
  )
}

# Token counting ----------------------------------------------------------

# https://developers.openai.com/api/docs/guides/token-counting
method(count_tokens, ProviderOpenAI) <- function(
  provider,
  ...,
  system_prompt = NULL,
  tools = list(),
  type = NULL
) {
  req <- base_request(provider)
  req <- req_url_path_append(req, "responses/input_tokens")

  turns <- c(
    if (!is.null(system_prompt)) list(SystemTurn(system_prompt)),
    list(user_turn(...))
  )
  input <- compact(unlist(as_json(provider, turns), recursive = FALSE))
  tools <- chat_body_tools(provider, tools)

  if (!is.null(type)) {
    text <- list(
      format = list(
        type = "json_schema",
        name = "structured_data",
        schema = as_json(provider, type),
        strict = TRUE
      )
    )
  } else {
    text <- NULL
  }

  body <- compact(list(
    input = input,
    model = provider@model,
    tools = tools,
    text = text
  ))

  req <- req_body_json(req, body)
  req <- req_headers(req, !!!provider@extra_headers)

  resp <- req_perform(req)
  resp_body_json(resp)$input_tokens
}

# ellmer -> OpenAI --------------------------------------------------------------

method(as_json, list(ProviderOpenAI, Turn)) <- function(
  provider,
  x,
  ...
) {
  x <- turn_contents_expand(x)

  # While the user turn can contain multiple contents, the assistant turn
  # can't. Fortunately, we can send multiple user turns with out issue.
  as_json(provider, x@contents, ..., role = x@role)
}

method(as_json, list(ProviderOpenAI, ContentText)) <- function(
  provider,
  x,
  ...,
  role
) {
  type <- if (role %in% c("user", "system")) "input_text" else "output_text"
  list(
    role = role,
    content = list(list(type = type, text = x@text))
  )
}

method(as_json, list(ProviderOpenAI, ContentThinking)) <- function(
  provider,
  x,
  ...
) {
  x@extra
}

method(as_json, list(ProviderOpenAI, ContentImageRemote)) <- function(
  provider,
  x,
  ...
) {
  list(
    role = "user",
    content = list(
      list(type = "input_image", image_url = x@url)
    )
  )
}

method(as_json, list(ProviderOpenAI, ContentImageInline)) <- function(
  provider,
  x,
  ...
) {
  list(
    role = "user",
    content = list(
      list(
        type = "input_image",
        image_url = paste0("data:", x@type, ";base64,", x@data)
      )
    )
  )
}

method(as_json, list(ProviderOpenAI, ContentPDF)) <- function(
  provider,
  x,
  ...
) {
  # https://platform.openai.com/docs/guides/pdf-files?api-mode=responses
  list(
    role = "user",
    content = list(list(
      type = "input_file",
      filename = x@filename,
      file_data = paste0("data:application/pdf;base64,", x@data)
    ))
  )
}

method(as_json, list(ProviderOpenAI, ContentToolRequest)) <- function(
  provider,
  x,
  ...
) {
  list(
    type = "function_call",
    call_id = x@id,
    name = x@name,
    arguments = to_json(x@arguments)
  )
}

method(as_json, list(ProviderOpenAI, ContentToolResult)) <- function(
  provider,
  x,
  ...
) {
  list(
    type = "function_call_output",
    call_id = x@request@id,
    output = tool_string(x)
  )
}

method(as_json, list(ProviderOpenAI, ToolDef)) <- function(
  provider,
  x,
  ...
) {
  list(
    type = "function",
    name = x@name,
    description = x@description,
    strict = TRUE,
    parameters = as_json(provider, x@arguments, ...)
  )
}

# Batched requests -------------------------------------------------------------

method(has_batch_support, ProviderOpenAI) <- function(provider) {
  TRUE
}

# https://platform.openai.com/docs/api-reference/batch
method(batch_submit, ProviderOpenAI) <- function(
  provider,
  conversations,
  type = NULL
) {
  path <- withr::local_tempfile()

  # First put the requests in a file
  # https://platform.openai.com/docs/api-reference/batch/request-input
  requests <- map(seq_along(conversations), function(i) {
    body <- chat_body(
      provider,
      stream = FALSE,
      turns = conversations[[i]],
      type = type
    )

    list(
      custom_id = paste0("chat-", i),
      method = "POST",
      url = "/v1/responses",
      body = body
    )
  })
  json <- map_chr(requests, to_json)
  writeLines(json, path)
  # Then upload it
  uploaded <- openai_upload(provider, path)

  # Now we can submit the
  req <- base_request(provider)
  req <- req_url_path_append(req, "/batches")
  req <- req_body_json(
    req,
    list(
      input_file_id = uploaded$id,
      endpoint = "/v1/responses",
      completion_window = "24h"
    )
  )

  resp <- req_perform(req)
  resp_body_json(resp)
}

# https://platform.openai.com/docs/api-reference/files/create
openai_upload <- function(provider, path, purpose = "batch") {
  req <- base_request(provider)
  req <- req_url_path_append(req, "/files")
  req <- req_body_multipart(
    req,
    purpose = purpose,
    file = curl::form_file(path)
  )
  req <- req_progress(req, "up")

  resp <- req_perform(req)
  resp_body_json(resp)
}

# https://platform.openai.com/docs/api-reference/batch/retrieve
method(batch_poll, ProviderOpenAI) <- function(provider, batch) {
  req <- base_request(provider)
  req <- req_url_path_append(req, "/batches/", batch$id)

  resp <- req_perform(req)
  resp_body_json(resp)
}
method(batch_status, ProviderOpenAI) <- function(provider, batch) {
  list(
    working = batch$status != "completed",
    n_processing = batch$request_counts$total - batch$request_counts$completed,
    n_succeeded = batch$request_counts$completed,
    n_failed = batch$request_counts$failed
  )
}


# https://platform.openai.com/docs/api-reference/batch/retrieve
method(batch_retrieve, ProviderOpenAI) <- function(provider, batch) {
  # output file
  path_output <- withr::local_tempfile()
  openai_download_file(provider, batch$output_file_id, path_output)
  json <- read_ndjson(path_output, fallback = openai_json_fallback)

  # error file
  if (length(batch$error_file_id) == 1) {
    path_error <- withr::local_tempfile()
    openai_download_file(provider, batch$error_file_id, path_error)

    json <- c(json, read_ndjson(path_error, fallback = openai_json_fallback))
  }

  ids <- as.numeric(gsub("chat-", "", map_chr(json, "[[", "custom_id")))
  results <- lapply(json, "[[", "response")
  results[order(ids)]
}

openai_download_file <- function(provider, id, path) {
  req <- base_request(provider)
  req <- req_url_path_append(req, "/files/", id, "/content")
  req <- req_progress(req, "down")
  req_perform(req, path = path)

  invisible(path)
}
openai_json_fallback <- function(line) {
  list(
    custom_id = extract_custom_id(line),
    response = list(status_code = 500)
  )
}
extract_custom_id <- function(json_string) {
  pattern <- '"custom_id"\\s*:\\s*"([^"]*)"'
  match <- regexec(pattern, json_string)

  result <- regmatches(json_string, match)[[1]]
  if (length(result) != 2) {
    NA_character_
  } else {
    result[[2]] # Second element is the captured group
  }
}

method(batch_result_turn, ProviderOpenAI) <- function(
  provider,
  result,
  has_type = FALSE
) {
  if (result$status_code == 200) {
    value_turn(provider, result$body, has_type = has_type)
  } else {
    NULL
  }
}

# Helpers ------------------------------------------------------------------

is_openai_reasoning <- function(model) {
  # https://platform.openai.com/docs/models/compare
  startsWith(model, "o") || startsWith(model, "gpt-5")
}
