/** Shared API base client */

import { isTauri, getBackendPort } from './tauri'

export interface IApiError {
  status: number
  message: string
  detail?: string
}

/**
 * Client version advertised in the ``X-Client-Version`` request header.
 *
 * The server-side rollout policy uses this to decide whether to issue
 * legacy (no-sid) JWTs to old clients during the U10 migration window.
 * Bump this when shipping breaking auth changes; the rollout doc
 * explains the policy and the cutoff version.
 */
export const CLIENT_VERSION = '0.5.0'

/** 中文 HTTP 状态码提示，覆盖所有用户可见的错误状态。 */
const HTTP_STATUS_ZH: Record<number, string> = {
  400: '请求参数错误',
  401: '认证失败，请重新登录',
  403: '没有操作权限',
  404: '资源不存在',
  408: '请求超时',
  409: '资源冲突',
  413: '请求数据过大',
  422: '数据验证失败',
  429: '请求过于频繁，请稍后重试',
  500: '服务器内部错误',
  501: '功能未实现',
  502: '网关错误',
  503: '服务暂不可用',
  504: '网关超时',
}

/**
 * 将 fetch 网络错误（WebKit 的 "Load failed"、Chrome 的 "Failed to fetch"）
 * 转换为中文提示，保持原始错误作为 detail。
 */
function wrapNetworkError(e: unknown): never {
  if (e instanceof TypeError && /load failed|failed to fetch/i.test(e.message)) {
    throw new Error('网络连接失败，请检查服务是否已启动')
  }
  throw e
}

let _dynamicBaseURL = ''

/**
 * Initialize the dynamic base URL for Tauri (sidecar backend).
 *
 * In dev mode (``npm run tauri dev``) the frontend is served by Vite at
 * ``http://localhost:5173`` and Vite proxies ``/api`` to the backend.
 * Setting ``_dynamicBaseURL`` here would bypass the proxy and send
 * requests directly to ``http://127.0.0.1:<port>``, triggering CORS
 * errors (the backend returns 401 without CORS headers on auth
 * failures). We therefore leave ``_dynamicBaseURL`` empty in dev so
 * requests stay same-origin and go through the Vite proxy.
 *
 * In production (Tauri build) there is no Vite proxy, so we must point
 * directly at the sidecar port.
 */
export async function initApiBaseURL(): Promise<void> {
  if (isTauri() && !import.meta.env.DEV) {
    const port = await getBackendPort()
    _dynamicBaseURL = `http://127.0.0.1:${port}`
  }
}

/** Get the current dynamic base URL (empty string when not in Tauri). */
export function getDynamicBaseURL(): string {
  return _dynamicBaseURL
}

/**
 * Token provider hook.
 *
 * The auth store registers itself here via ``setTokenProvider`` so that
 * ``BaseApiClient`` can read the current access token without a circular
 * import (base.ts ← auth.ts ← stores/auth.ts ← base.ts).
 */
type TokenProvider = () => string | null
type RefreshProvider = () => Promise<string | null>
type UnauthorizedHandler = () => void
type PreEmptiveRefreshProvider = () => boolean

let _tokenProvider: TokenProvider | null = null
let _refreshProvider: RefreshProvider | null = null
let _unauthorizedHandler: UnauthorizedHandler | null = null
let _preEmptiveRefreshProvider: PreEmptiveRefreshProvider | null = null

/** Register the access-token provider (called by the auth store on init). */
export function setTokenProvider(provider: TokenProvider): void {
  _tokenProvider = provider
}

/** Register the refresh-token provider (called by the auth store on init). */
export function setRefreshProvider(provider: RefreshProvider): void {
  _refreshProvider = provider
}

/** Register a handler invoked when refresh fails (redirect to /login). */
export function setUnauthorizedHandler(handler: UnauthorizedHandler): void {
  _unauthorizedHandler = handler
}

/**
 * Register a hook that returns true when the auth store considers the
 * access token too close to expiry.  When true, the next outgoing
 * request will be preceded by a silent refresh — the user never sees
 * a 401 caused by their own (legitimate) token expiring.
 */
export function setPreEmptiveRefreshProvider(provider: PreEmptiveRefreshProvider): void {
  _preEmptiveRefreshProvider = provider
}

export class BaseApiClient {
  protected baseUrl: string

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  /**
   * Resolve the effective URL for a request path.
   *
   * - Paths starting with ``/api/`` are treated as absolute (no ``baseUrl``
   *   prefix).
   * - Other paths are prefixed with ``this.baseUrl``.
   * - In Tauri mode, the dynamic sidecar base URL is prepended.
   */
  private _resolveUrl(path: string): string {
    const isAbsoluteApi = path.startsWith('/api/')
    const suffix = isAbsoluteApi ? path : `${this.baseUrl}${path}`
    return _dynamicBaseURL ? `${_dynamicBaseURL}${suffix}` : suffix
  }

  /**
   * Build the request headers, attaching the JWT if available.
   */
  private _buildHeaders(options: RequestInit): Record<string, string> {
    const headers: Record<string, string> = {
      ...options.headers as Record<string, string>,
    }
    if (!(options.body instanceof FormData)) {
      headers['Content-Type'] = 'application/json'
    }
    const token = _tokenProvider?.()
    if (token) {
      headers['Authorization'] = `Bearer ${token}`
    }
    // U10 backwards-compat: tell the server what client version we are.
    // The server uses this to decide whether to issue legacy (no-sid)
    // tokens for old clients during the migration window. Once the
    // cutoff version is removed server-side, this header becomes a
    // no-op. See docs/migrations/2026-06-20-client-version-rollout.md
    headers['X-Client-Version'] = CLIENT_VERSION
    return headers
  }

  /**
   * Send a request with a custom ``Authorization`` header that
   * overrides the in-memory access token.  Used by the cold-start
   * ``/auth/whoami`` call where only the refresh token is available
   * and the auth store's access token is empty.
   */
  protected async requestWithToken<H extends Record<string, string>, T>(
    path: string,
    headers: H,
    options: RequestInit = {},
  ): Promise<T> {
    const response = await this._sendWithHeaders(path, options, headers)
    if (!response.ok) {
      throw await this._buildError(response)
    }
    const text = await response.text()
    if (!text) {
      return null as unknown as T
    }
    try {
      return JSON.parse(text) as T
    } catch {
      return text as unknown as T
    }
  }

  /**
   * Send a request, retrying once with a refreshed token on 401.
   *
   * Before each request we also call ``silentRefresh()`` when
   * ``auth.shouldRefresh`` is true (pre-emptive refresh) so that a
   * soon-to-expire access token never causes a 401 in the first place.
   *
   * If the refresh fails, the unauthorized handler is invoked
   * (typically redirecting to /login) and the original 401 error is
   * re-thrown.
   */
  protected async request<T>(path: string, options: RequestInit = {}): Promise<T> {
    // Pre-emptive refresh: if the access token is about to expire
    // (within the headroom window), refresh BEFORE sending the request.
    // This is the only place that triggers the pre-emptive refresh
    // path; the 401-retry path below handles the case where the token
    // expires between calls.
    if (this._shouldPreemptivelyRefresh()) {
      try {
        await this._silentRefreshForPreEmption()
      } catch {
        /* silent refresh failed — fall through; the 401 retry path
           below will produce a 401 and trigger the unauthorized handler */
      }
    }

    const response = await this._send(path, options)

    if (response.status === 401 && _refreshProvider) {
      const newToken = await _refreshProvider()
      if (newToken) {
        // Retry the original request with the new token
        const retried = await this._send(path, options)
        if (!retried.ok) {
          throw await this._buildError(retried)
        }
        const text = await retried.text()
        if (!text) {
          return null as unknown as T
        }
        try {
          return JSON.parse(text) as T
        } catch {
          return text as unknown as T
        }
      }
      // Refresh failed — notify the app to redirect to login
      _unauthorizedHandler?.()
    }

    if (!response.ok) {
      throw await this._buildError(response)
    }

    // Some endpoints (logout) return empty body; guard against JSON parse errors
    const text = await response.text()
    if (!text) {
      return null as unknown as T
    }
    try {
      return JSON.parse(text) as T
    } catch {
      return text as unknown as T
    }
  }

  /** Whether the current access token should be refreshed before the next request. */
  private _shouldPreemptivelyRefresh(): boolean {
    const provider = _preEmptiveRefreshProvider
    if (!provider) return false
    try {
      return provider()
    } catch {
      return false
    }
  }

  private async _silentRefreshForPreEmption(): Promise<void> {
    if (!_refreshProvider) return
    await _refreshProvider()
  }

  /** Low-level fetch with headers + URL resolved. */
  private async _send(path: string, options: RequestInit): Promise<Response> {
    const effectiveUrl = this._resolveUrl(path)
    const headers = this._buildHeaders(options)
    try {
      return await fetch(effectiveUrl, { ...options, headers })
    } catch (e) {
      wrapNetworkError(e)
    }
  }

  /** Low-level fetch with caller-supplied headers (overrides the default Authorization). */
  private async _sendWithHeaders(
    path: string,
    options: RequestInit,
    headers: Record<string, string>,
  ): Promise<Response> {
    const effectiveUrl = this._resolveUrl(path)
    const merged: Record<string, string> = {
      ...(options.headers as Record<string, string> | undefined),
      ...headers,
    }
    if (!(options.body instanceof FormData)) {
      merged['Content-Type'] = 'application/json'
    }
    try {
      return await fetch(effectiveUrl, { ...options, headers: merged })
    } catch (e) {
      wrapNetworkError(e)
    }
  }

  private async _buildError(response: Response): Promise<IApiError> {
    const error: IApiError = {
      status: response.status,
      message: HTTP_STATUS_ZH[response.status] ?? response.statusText,
    }
    try {
      const body = await response.json()
      error.detail = body.detail ?? body.message
      error.message = error.detail ?? error.message
    } catch {
      // response body is not JSON
    }
    return error
  }

  /** Get the current access token (for WebSocket URL construction). */
  protected getToken(): string | null {
    return _tokenProvider?.() ?? null
  }

  /**
   * Create a WebSocket connection, attaching the JWT as a query parameter.
   *
   * The server's AuthMiddleware accepts ``?token=<jwt>`` as a fallback for
   * WebSocket connections where setting the Authorization header is not
   * possible.
   */
  protected createWebSocket(path: string): WebSocket {
    const token = _tokenProvider?.()
    const tokenSuffix = token ? `?token=${encodeURIComponent(token)}` : ''

    if (_dynamicBaseURL) {
      const url = new URL(_dynamicBaseURL)
      const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
      return new WebSocket(`${protocol}//${url.host}${this.baseUrl}${path}${tokenSuffix}`)
    }
    const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
    const host = window.location.host
    return new WebSocket(`${protocol}//${host}${this.baseUrl}${path}${tokenSuffix}`)
  }
}
