import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy';
import { appConfig } from '@/config/env';
import { ApiError } from '@/lib/errors';
import type { z } from 'zod';
import {
  BillingStatusSchema,
  CheckoutSessionResponseSchema,
  DepotObjectSchema,
  FirebaseTokenResponseSchema,
  GetLivekitTokenResponseSchema,
  HumanSchema,
  ListInvitationsResponseSchema,
  ListNetworksResponseSchema,
  NetworkSchema,
  NetworkUsageSchema,
  PortalSessionResponseSchema,
  PrepareUploadResponseSchema,
  SignInResponseSchema,
} from './types';
import type {
  AcceptInvitationRequest,
  AddMembersRequest,
  BillingCadence,
  CreateNetworkRequest,
  PrepareUploadRequest,
  RequestCodeRequest,
  RevokeInvitationRequest,
  SignInRequest,
} from './types';

/**
 * HTTP transport for Orion. Holds the bearer token as private state — the auth
 * store pushes it in via {@link setToken} on sign-in / restore and clears it
 * on sign-out. The client itself has no opinion about what a 401 means; it
 * just throws, and the query-client onError handler is the single place that
 * turns a 401 into a session invalidation.
 */
class ApiClient {
  private token: string | null = null;

  constructor(private readonly baseUrl: string) {}

  setToken(token: string | null): void {
    this.token = token;
  }

  private async fetch(
    method: string,
    path: string,
    body?: unknown,
  ): Promise<Response> {
    const headers: Record<string, string> = {};

    if (body) {
      headers['Content-Type'] = 'application/json';
    }

    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`;
    }

    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
    });

    if (response.status === 401) {
      throw new ApiError(401, 'Unauthorized');
    }

    if (!response.ok) {
      const text = await response.text().catch(() => 'Unknown error');
      throw new ApiError(response.status, text);
    }

    return response;
  }

  private async request<T>(
    schema: z.ZodType<T>,
    method: string,
    path: string,
    body?: unknown,
  ): Promise<T> {
    const response = await this.fetch(method, path, body);
    const json = await response.json();
    return schema.parse(json);
  }

  private async requestVoid(
    method: string,
    path: string,
    body?: unknown,
  ): Promise<void> {
    await this.fetch(method, path, body);
  }

  // --- Auth ---

  async requestCode(data: RequestCodeRequest): Promise<void> {
    await this.requestVoid('POST', '/auth/request-code', data);
  }

  async signIn(data: SignInRequest) {
    return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data);
  }

  async me() {
    return this.request(HumanSchema, 'GET', '/auth/me');
  }

  async signOut(): Promise<void> {
    await this.requestVoid('POST', '/auth/sign-out');
  }

  async getFirebaseToken() {
    return this.request(
      FirebaseTokenResponseSchema,
      'POST',
      '/auth/firebase-token',
    );
  }

  // TODO: security: require passing in the particle id once api deprecates this
  async getParticleDownloadUrl(objectId: string): Promise<string> {
    const response = await this.fetch('GET', `/particles/${objectId}/download`);
    const data = await response.json();
    return data.url;
  }

  // --- Settings ---

  async updateSettings(data: {
    email_notifications_enabled?: boolean;
  }): Promise<void> {
    await this.requestVoid('PATCH', '/humans/me/settings', data);
  }

  // --- Avatar ---

  /**
   * Upload a new profile picture. The endpoint takes the raw image bytes as
   * the request body (not multipart), so we stream the file directly via
   * expo-file-system rather than the JSON `fetch` helper.
   */
  async uploadAvatar(fileUri: string, mimeType: string): Promise<void> {
    const headers: Record<string, string> = { 'Content-Type': mimeType };
    if (this.token) {
      headers['Authorization'] = `Bearer ${this.token}`;
    }
    const result = await uploadAsync(
      `${this.baseUrl}/humans/me/avatar`,
      fileUri,
      {
        httpMethod: 'PUT',
        uploadType: FileSystemUploadType.BINARY_CONTENT,
        headers,
      },
    );
    if (result.status === 401) {
      throw new ApiError(401, 'Unauthorized');
    }
    if (result.status < 200 || result.status >= 300) {
      throw new ApiError(result.status, result.body || 'Avatar upload failed');
    }
  }

  async deleteAvatar(): Promise<void> {
    await this.requestVoid('DELETE', '/humans/me/avatar');
  }

  async getAvatarDownloadUrl(objectId: string): Promise<string> {
    const response = await this.fetch('GET', `/humans/avatar/${objectId}`);
    const data = await response.json();
    return data.url;
  }

  // --- Push notification tokens ---

  async registerPushToken(data: {
    token: string;
    platform: 'ios' | 'android';
    app_version: string;
  }): Promise<void> {
    await this.requestVoid('POST', '/humans/me/push-tokens', data);
  }

  async unregisterPushToken(token: string): Promise<void> {
    await this.requestVoid('DELETE', '/humans/me/push-tokens', { token });
  }

  // --- Depot ---

  async prepareUpload(data: PrepareUploadRequest) {
    return this.request(
      PrepareUploadResponseSchema,
      'POST',
      '/depot/upload',
      data,
    );
  }

  async confirmUpload(objectId: string) {
    return this.request(
      DepotObjectSchema,
      'POST',
      `/depot/objects/${objectId}/confirm`,
    );
  }

  // --- Networks ---

  async listNetworks() {
    return this.request(ListNetworksResponseSchema, 'GET', '/networks');
  }

  async createNetwork(data: CreateNetworkRequest) {
    return this.request(NetworkSchema, 'POST', '/networks', data);
  }

  async getNetwork(id: string) {
    return this.request(NetworkSchema, 'GET', `/networks/${id}`);
  }

  async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
    await this.requestVoid('POST', `/networks/${networkId}/members`, data);
  }

  async removeMember(networkId: string, humanId: string): Promise<void> {
    await this.requestVoid(
      'DELETE',
      `/networks/${networkId}/members/${humanId}`,
    );
  }

  // --- Invitations ---

  async listNetworkInvitations(networkId: string) {
    return this.request(
      ListInvitationsResponseSchema,
      'GET',
      `/networks/${networkId}/invitations`,
    );
  }

  async listMyInvitations() {
    return this.request(ListInvitationsResponseSchema, 'GET', '/invitations');
  }

  async acceptInvitation(data: AcceptInvitationRequest): Promise<void> {
    await this.requestVoid('POST', '/invitations/accept', data);
  }

  async revokeInvitation(
    networkId: string,
    data: RevokeInvitationRequest,
  ): Promise<void> {
    await this.requestVoid(
      'DELETE',
      `/networks/${networkId}/invitations`,
      data,
    );
  }

  // --- LiveKit ---

  async getLivekitToken(networkId: string, streamId: string) {
    return this.request(
      GetLivekitTokenResponseSchema,
      'POST',
      '/livekit/token',
      { network_id: networkId, stream_id: streamId },
    );
  }

  // --- Billing (network admin only) ---

  async getNetworkBilling(networkId: string) {
    return this.request(
      BillingStatusSchema,
      'GET',
      `/networks/${networkId}/billing`,
    );
  }

  async createCheckoutSession(networkId: string, cadence: BillingCadence) {
    return this.request(
      CheckoutSessionResponseSchema,
      'POST',
      `/networks/${networkId}/billing/checkout-session`,
      { cadence },
    );
  }

  async createPortalSession(networkId: string) {
    return this.request(
      PortalSessionResponseSchema,
      'POST',
      `/networks/${networkId}/billing/portal-session`,
    );
  }

  async getNetworkUsage(networkId: string) {
    return this.request(
      NetworkUsageSchema,
      'GET',
      `/networks/${networkId}/usage`,
    );
  }
}

export const apiClient = new ApiClient(appConfig.orionUrl);
