//
// Copyright © 2024 MediaMall Technologies, Inc.  All rights reserved.
//

import
{
  NativeModules,
  Platform,
  NativeEventEmitter,
  type EmitterSubscription
} from 'react-native';

/**
 * @ignore
 */
const LINKING_ERROR =
  `The package 'react-native-younify-connect-sdk' doesn't seem to be linked. Make sure: \n\n` +
  Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) +
  '- You rebuilt the app after installing the package\n' +
  '- You are not using Expo Go\n';

/**
 * @ignore
 */
const YounifyConnectSDK = NativeModules.YounifyConnectSDK
  ? NativeModules.YounifyConnectSDK
  : new Proxy(
      {},
      {
        get()
        {
          throw new Error(LINKING_ERROR);
        },
      }
    );

const connectEvt = new NativeEventEmitter(YounifyConnectSDK);

/** Error codes reported by the SDK. */
export enum ErrorCode
{
    /** An unknown error occurred. */
    Unknown = "-10000",

    /** The current version of the SDK is no longer supported. */
    SDKVersionNotSupported = "-10001",

    /** The system is currently down for maintenance. */
    SystemMaintenance = "-10002",

    /** A network error occurred. The user is either offline or the server returned an invalid response. The specific http status code is available in the statusCode property of the userInfo dictionary. */
    NetworkRequestFailed = "-10003",

    /** The requested operation has been canceled. */
    OperationCanceled = "-10004",

    /** An invalid argument was encountered. */
    InvalidArgument = "-10005",


    /** The service fetch operation failed. */
    ServiceFetchingFailed = "-20000",

    /** The service linking operation failed. */
    ServiceLinkingFailed = "-20001",

    /** The service is not supported by the SDK. */
    ServiceUnsupported = "-20002",

    /** The service is temporarily unavailable. */
    ServiceTemporarilyUnavailable = "-20003",

    /** The service login failed. The user should verify their login information. */
    ServiceLoginFailed = "-20004",

    /** The service profile was not found. The user should select another profile. */
    ServiceProfileNotFound = "-20005",

    /** The service profile pin was not correct. The user should enter their pin again. */
    ServiceProfilePinMismatch = "-20006",

    /** This service content may no longer be available. */
    ServiceContentNotFound = "-20007",


    /** The view context is invalid. */
    InvalidViewContext = "-30000",


    /** The user tried to perform an action that requires privacy policy consent. */
    UserConsentRequired = "-40000"
};

/**
 * Contains partial response data for an operation that was promise rejected.
 *
 * Some operations can return valid, partial data despite having errors occur that an application may which to treat as non-fatal.
 */
export interface ErrorUserInfoPartialResult
{
  /**
   * Holds the response that would have been returned had an error not occurred but in an incomplete or partial state. The format is identical to the promised value of the original request.
   */
  partialResult?: any | null;
}

/**
 * Contains extra information for {@link ErrorCode.NetworkRequestFailed} errors.
 */
export interface ErrorUserInfoNetworkRequestFailed extends ErrorUserInfoPartialResult
{
  /** HTTP status code. */
  statusCode: number;
}

/**
 * Contains extra information for {@link ErrorCode.InvalidArgument} errors.
 */
export interface ErrorUserInfoInvalidArgument extends ErrorUserInfoPartialResult
{
  /** Name of the parameter that is invalid. */
  paramName: string;
}

/**
 * Contains extra information for {@link ErrorCode.ServiceLinkingFailed} errors.
 */
export interface ErrorUserInfoServiceLinkingFailed extends ErrorUserInfoPartialResult
{
  /** Streaming service specific error message. */
  serviceMessage: string;
}

/** Error object. */
export interface Error extends globalThis.Error
{
  /** Code */
  code: ErrorCode | string;

  /** Message */
  message: string;

  /** Info */
  userInfo: ErrorUserInfoServiceLinkingFailed | ErrorUserInfoInvalidArgument | ErrorUserInfoNetworkRequestFailed | ErrorUserInfoPartialResult | object | null;
}

/**
 * Log level.
 */
export enum LogLevel
{
  /** Trace. */
  Trace,

  /** Debug. */
  Debug,

  /** Information */
  Information,

  /** Warning */
  Warning,

  /** Error */
  Error,

  /** Critical */
  Critical,

  /** None */
  None
}

/**
 * Log listener.
 */
export interface LogListener
{
  /** Receives messages from the sdk. */
  onMessage(level: LogLevel, message: string): void;
}

/**
 * Token handler.
 *
 * Implement this handler to receive token events from the sdk and to handle backend token renewals. The instance can be set in {@link ConnectOptions}.
*/
export interface TokenHandler
{
  /**
   * Called when an access token is missing or expired that must be renewed via the backend.
   *
   * @param expiredAccessToken - The user's expired access token.
   * @param refreshToken - The user's refresh token for the expired access token.
   * @param renewed - The completion handler to be called with the new access and refresh tokens on success or nil if it fails.
   *
   * This method will be called when an access token is missing or expired and needs renewing via the backend. The SDK will first attempt automatic renewal of tokens via the frontend, but if this fails because
   * the refresh token is missing, revoked or of some other error, a backend renewal will be required. Backend renewals require proxying the request through your own backend api to Younify using your private api key.
   * The renewed completion handler must be called with the new tokens or null if an error occurs. As a convenience, {@link onRenewed} will be called afterward on success to persist the new tokens in device storage.
   */
  onRenew(expiredAccessToken: string | null, refreshToken: string | null, renewed: (newAccessToken: string | null, newRefreshToken: string | null) => void): void;

  /**
   * Called when an access token and refresh token are renewed and need persisted.
   *
   * @param newAccessToken - The user's newly acquired access token.
   * @param newRefreshToken - The user's newly acquired refresh token.
   *
   * This method will be called when an access token and refresh token have been renewed and need persisted. The listener should persisted the new tokens in device local storage in a non-cloud-synchronized manner.
   * If the tokens are accidentally shared between multiple devices, frontend renewal on one device may cause the other devices to be logged out triggering a backend renewal request via {@link onRenew}.
   */
  onRenewed(newAccessToken: string, newRefreshToken: string): void;
}

interface CancellationTicketInternal
{
  id: string;
}

/**
 * A Cancellation ticket.
 *
 * A special ticket that allows coordination of cancellation operations between a requestor and worker.
 *
 */
export class CancellationTicket
{
  private static _nextId: number = 1;
  private static _none: CancellationTicket = new CancellationTicket();

  static get none(): CancellationTicket
  {
    return CancellationTicket._none;
  }

  private readonly id: string;
  private canceled: boolean;

  constructor()
  {
    this.id = (CancellationTicket._nextId++).toString() + '-' + Date.now().toString(36);
    this.canceled = false;
  }

  /**
   * Whether a cancellation has been requested.
   */
  get isCancellationRequested(): boolean
  {
    return this.canceled;
  }

  /**
   * @throws {@link Error} if {@link isCancellationRequested} is set.
   */
  throwIfCancellationRequested() : void | never
  {
    if (this.isCancellationRequested)
      throw { code: ErrorCode.OperationCanceled, message: 'The requested operation has been canceled.', userInfo: null } as Error
  }

  /**
   * Requests that the coordinated operation be canceled and causes {@link isCancellationRequested} to be set.
   *
   * @returns A promise that resolves to a bool indicating whether the cancel was successful.
   */
  async cancel() : Promise<void>
  {
    if (this === CancellationTicket._none)
      return;

    this.canceled = true;
    return await YounifyConnectSDK.cancel(this.id);
  }
}

/**
 * Younify Connect SDK startup options.
 *
 * Set the properties of this object to configure the sdk.
 * The {@link key} and {@link tokenHandler} properties are required.
 * The {@link accessToken} and {@link refreshToken} properties are required for linking and fetching service content for the user and can be set at a later time if unknown at startup. The tokens can be created from the backend api.
 * The {@link tokenHandler} must be set to handle updated tokens and requests from the sdk to renew tokens from the backend api.
 */
 export interface ConnectOptions
 {
  /** Required key. */
  key: string | null;

  /** Minimum log level. Log statements below {@link logLevel} will not be sent to {@link logListener}. */
  logLevel: LogLevel;

  /** Optional log listener for receiving log events. */
  logListener?: LogListener | null;

  /** Optional token handler for handling token events and renewals. */
  tokenHandler: TokenHandler | null;

  /** The user's access token if known at startup. */
  accessToken?: string | null;

  /** The user's refresh token if known at startup. */
  refreshToken?: string | null;

  /** Extra options. */
  extra?: object | null;
}

/**
 * Streaming service link.
 *
 * Contains informative user properties about the linked streaming service.
 */
export interface StreamingServiceLink
{
  /** Service username or email. */
  username: string | null;

  /** Selected profile name if the service has profiles. */
  profileName?: string;

  /** Selected profile image if the service has profiles. */
  profileImageUrl?: string;

  /** Whether this profile is pin protected if the service has profiles. */
  isProfilePINProtected: boolean;

  /** Whether this link is broken or otherwise has a problem that requires management or unlinking to address. */
  isBroken: boolean;
}

class StreamingServiceLinkFactory
{
  static createFromJsonObj(obj: any): StreamingServiceLink
  {
    return obj;
  }
}

/**
 * Streaming service.
 *
 * Represents a streaming service.
 */
export interface StreamingService
{
  /** Streaming service name. */
  name: string;

  /** Streaming service small thumbnail image url. */
  smallThumbnailUrl?: string;

  /** Streaming service large thumbnail image url. */
  largeThumbnailUrl?: string;

  /** Streaming service overlay image url. */
  overlayThumbnailUrl?: string;

  /** Whether this streaming service is currently available or is undergoing maintenance. */
  isAvailable: boolean;

  /** Streaming service link info if this service was linked by the user. */
  link?: StreamingServiceLink;

  /** Tests for equality. */
  equals(obj: StreamingService): boolean;
}

class StreamingServiceFactory
{
  private static readonly StreamingServiceEquals = function (this: StreamingService, obj: StreamingService): boolean { return this.name == obj.name; };

  static createFromJsonObj(obj: any): StreamingService
  {
    let service = obj as StreamingService;
    service.equals = StreamingServiceFactory.StreamingServiceEquals;
    service.link = StreamingServiceLinkFactory.createFromJsonObj(service.link);
    return service;
  }

  static createFromJsonArray(obj: any[]): StreamingService[]
  {
    return obj.map(x => StreamingServiceFactory.createFromJsonObj(x));
  }
}

/**
 * Streaming service content type.
 *
 * The type of content the {@link StreamingContent} object represents.
 */
export enum StreamingContentType
{
  /** Invalid type */
  Invalid = -1,

  /** Folder */
  Folder = 0,

  /** Video */
  Video = 1
}

/**
 * Streaming service content.
 *
 * Represents a content item from a streaming service.
 */
export interface StreamingContent
{
  /** Content type. */
  type: StreamingContentType;

  /** Name in the {@link path} hierarchy derived from {@link season}, {@link episode} and {@link title}. */
  name: string;

  /** Title. */
  title: string;

  /** Overview, synopsis or description. */
  overview?: string;

  /** Duration in milliseconds. */
  duration: number;

  /** Air date in unix time. */
  airDate?: number;

  /** Small thumbnail image url. */
  smallThumbnailUrl?: string;

  /** Large thumbnail image url. */
  largeThumbnailUrl?: string;

  /** Streaming service specific content id for this item. */
  itemID?: string;

  /** Series title. */
  series?: string;

  /** Series season. */
  season?: string;

  /** Series episode. */
  episode?: string;

  /** Release year. */
  releaseYear?: string;

  /** Content rating. */
  contentRating?: string;

  /** Streaming service specific watch now url. */
  watchNowUrl?: string;

  /** TMDB id for this content if available. */
  tmdbID?: string;

  /** TMDB id for this content's series if available. */
  tmdbSeriesID?: string;

  /** Path to this item as a sequence of parent {@link name} properties. */
  path: string[];
}

class StreamingContentFactory
{
  static createFromJsonObj(obj: any): StreamingContent
  {
    return obj;
  }

  static createFromJsonArray(obj: any[]): StreamingContent[]
  {
    return obj.map(x => StreamingContentFactory.createFromJsonObj(x));
  }
}

/**
 * Streaming service content details.
 *
 * Holds additional content item details for a {@link StreamingContent} object.
 */
export interface StreamingContentDetails
{
  /** Estimated duration in milliseconds of this item including advertisements. */
  estimatedDuration: number;
}

class StreamingContentDetailsFactory
{
  static createFromJsonObj(obj: any): StreamingContentDetails
  {
    return obj;
  }
}

/**
 * Streaming service content category.
 *
 * Represents a streaming service category for a user.
 */
export interface StreamingCategory
{
  /** Category name. */
  name: string;

  /** Tests for equality. */
  equals(obj: StreamingCategory): boolean;
}

class StreamingCategoryImpl implements StreamingCategory
{
  name: string;

  constructor(name: string);
  constructor(category: StreamingCategory);
  constructor(nameOrCategory: string | StreamingCategory)
  {
    if (typeof nameOrCategory === 'string')
      this.name = nameOrCategory;
    else
      this.name = nameOrCategory.name;
  }

  equals(obj: StreamingCategory): boolean
  {
    return this.name == obj.name;
  }
}

class StreamingCategoryFactory
{
  static createFromJsonObj(obj: any): StreamingCategory
  {
    return new StreamingCategoryImpl(obj);
  }

  static createFromJsonArray(obj: any[]): StreamingCategory[]
  {
    return obj.map(x => StreamingCategoryFactory.createFromJsonObj(x));
  }
}

/**
 * Well-known streaming service content categories.
 *
 * Holds a list of well-known streaming service categories.
*/
export namespace StreamingCategories
{
  /** Your Watchlist. */
  export const YourWatchlist: StreamingCategory = new StreamingCategoryImpl("Your Watchlist");

  /** Continue Watching. */
  export const ContinueWatching: StreamingCategory = new StreamingCategoryImpl("Continue Watching");

  /** Recommended for You. */
  export const RecommendedForYou: StreamingCategory = new StreamingCategoryImpl("Recommended for You");

  /** Trending &amp; Popular. */
  export const TrendingAndPopular: StreamingCategory = new StreamingCategoryImpl("Trending & Popular");

  /** Critically Acclaimed. */
  export const CriticallyAcclaimed: StreamingCategory = new StreamingCategoryImpl("Critically Acclaimed");
}

/**
 * Fetch content category service result.
 *
 * Holds the results of a fetch content request for a given streaming service.
 */
export interface FetchContentCategoryServiceResult
{
  /** Streaming service the content items belong to. */
  service: StreamingService;

  /** Content items for {@link service}. */
  content: StreamingContent[]
}

class FetchContentCategoryServiceResultFactory
{
  static createFromJsonObj(obj: any): FetchContentCategoryServiceResult
  {
    let res: FetchContentCategoryServiceResult = obj;
    res.service = StreamingServiceFactory.createFromJsonObj(res.service);
    res.content = StreamingContentFactory.createFromJsonArray(res.content);
    return res;
  }

  static createFromJsonArray(obj: any[]): FetchContentCategoryServiceResult[]
  {
    return obj.map(x => FetchContentCategoryServiceResultFactory.createFromJsonObj(x));
  }
}

/**
 * Fetch content category result.
 *
 * Holds the results of a fetch content request for a category and each streaming service
 */
export interface FetchContentCategoryResult
{
  /** Streaming category the results apply to. */
  category: StreamingCategory;

  /** Service results for the specified category. */
  services: FetchContentCategoryServiceResult[]
}

class FetchContentCategoryResultFactory
{
  static createFromJsonObj(obj: any): FetchContentCategoryResult
  {
    let res: FetchContentCategoryResult = obj;
    res.category = StreamingCategoryFactory.createFromJsonObj(res.category);
    res.services = FetchContentCategoryServiceResultFactory.createFromJsonArray(res.services);
    return res;
  }

  static createFromJsonArray(obj: any[]): FetchContentCategoryResult[]
  {
    return obj.map(x => FetchContentCategoryResultFactory.createFromJsonObj(x));
  }
}

interface LinkServiceResult
{
  service: StreamingService;

  success: boolean;
}

class LinkServiceResultFactory
{
  static createFromJsonObj(obj: any): LinkServiceResult
  {
    let res: LinkServiceResult = obj;
    res.service = StreamingServiceFactory.createFromJsonObj(res.service);
    return res;
  }
}

/**
 * Search content service result.
 *
 * Holds the results of a search content request for a given streaming service.
 */
export interface SearchContentServiceResult
{
  /** Streaming service the content items belong to. */
  service: StreamingService;

  /** Content items for {@link service}. */
  content: StreamingContent[]
}

class SearchContentServiceResultFactory
{
  static createFromJsonObj(obj: any): SearchContentServiceResult
  {
    let res: SearchContentServiceResult = obj;
    res.service = StreamingServiceFactory.createFromJsonObj(res.service);
    res.content = StreamingContentFactory.createFromJsonArray(res.content);
    return res;
  }

  static createFromJsonArray(obj: any[]): SearchContentServiceResult[]
  {
    return obj.map(x => SearchContentServiceResultFactory.createFromJsonObj(x));
  }
}

interface EventCategoryServiceError
{
  requestId: string;

  error: Error;

  service: StreamingService;
}

class EventCategoryServiceErrorFactory
{
  static createFromJsonObj(obj: any): EventCategoryServiceError
  {
    let res: EventCategoryServiceError = obj;
    res.service = StreamingServiceFactory.createFromJsonObj(res.service);
    return res;
  }
}

interface EventCategoryServiceComplete
{
  requestId: string;

  error: Error | null;

  category: StreamingCategory;

  service: StreamingService;

  content: StreamingContent[];
}

class EventCategoryServiceCompleteFactory
{
  static createFromJsonObj(obj: any): EventCategoryServiceComplete
  {
    let res: EventCategoryServiceComplete = obj;
    res.category = StreamingCategoryFactory.createFromJsonObj(res.category);
    res.service = StreamingServiceFactory.createFromJsonObj(res.service);
    res.content = StreamingContentFactory.createFromJsonArray(res.content);
    return res;
  }
}

interface EventSearchContentServiceComplete
{
  requestId: string;

  error: Error | null;

  service: StreamingService;

  content: StreamingContent[];
}

class EventSearchContentServiceCompleteFactory
{
  static createFromJsonObj(obj: any): EventSearchContentServiceComplete
  {
    let res: EventCategoryServiceComplete = obj;
    res.service = StreamingServiceFactory.createFromJsonObj(res.service);
    res.content = StreamingContentFactory.createFromJsonArray(res.content);
    return res;
  }
}

/**
 * Younify Connect SDK main interface.
 *
 * Caution: When invoking a method, the sdk uses a limited task based processing architecture that may delay the execution of new operations until previous ones complete.
 * Special care should be taken when performing sdk operations in the background while allowing the user to interact in the foreground. Long running background operations
 * could negatively impact the responsiveness of your app. If you choose to use background operations, consider making them as short as possible or cancel them when users
 * interact in the foreground.
 */
export interface Connect
{
  /**
   * Configures the SDK with the specified options.
   * @param options - The configuration options.
   *
   * Before using the sdk it must be configured by using this method with a valid {@link ConnectOptions} instance. It is safe to call this method repeatedly to change the options dynamically at runtime if desired.
  */
  configure(options: ConnectOptions): Promise<void>;

  /**
   * Sets the user's tokens.
   *
   * @param accessToken - The user's access token.
   * @param refreshToken - The user's refresh token.
   *
   * Set the user's access and refresh tokens that are required for accessing streaming services. User tokens can be obtained by creating a user object from the backend api.
   */
  setUserTokens(accessToken: string | null, refreshToken: string | null): Promise<void>;

  /**
   * Requests the user to consent to the privacy policy if it has not yet been accepted.
   *
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns A boolean indicating whether the user consented or not.
   *
   * Opens the privacy policy consent flow if the privacy policy has not yet been accepted or completes immediately with true if it has.
   */
  requestUserConsent(cancellationTicket: CancellationTicket | null): Promise<boolean>;

  /**
   * Link a streaming service.
   *
   * @param service - The service object to link.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns A boolean indicating whether the service was linked or not.
   *
   * Opens the service linking flow which involves a web view login for the specified service followed by profile selection and optional pin collection if supported.
   * If linking is successful, the success flag will be true. If linking was canceled then the success flag will be false. If linking fails the promise will reject.
   *
   * Caution: Given the task based architecture of the sdk and the asynchronous nature of this method, care should be taken when invoking it from user interactive
   * elements to debounce or otherwise prevent multiple invocations until the previous one completes.
   */
  linkService(service: StreamingService, cancellationTicket: CancellationTicket | null): Promise<boolean>;

  /**
   * Manage a linked service.
   *
   * @param service - The service object to manage.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns The service object that was managed.
   *
   * Opens the linked service management flow which allows the user to change their profile selection and pin if supported.
   *
   * Caution: Given the task based architecture of the sdk and the asynchronous nature of this method, care should be taken when invoking it from user interactive
   * elements to debounce or otherwise prevent multiple invocations until the previous one completes.
   */
  manageLinkedService(service: StreamingService, cancellationTicket: CancellationTicket | null): Promise<void>;

  /**
   * Unlink a linked service.
   *
   * @param service - The service object to unlink.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns The service object that was unlinked.
   *
   */
  unlinkService(service: StreamingService, cancellationTicket: CancellationTicket | null): Promise<void>;

  /**
   *
   * Fetch all services.
   *
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns All services.
   *
   * Retrieves a list of all services.
   */
  fetchServices(cancellationTicket: CancellationTicket | null): Promise<StreamingService[]>;

  /**
   * Fetch linked services.
   *
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns All linked services.
   *
   * Retrieves a list of all the user's linked services.
   */
  fetchLinkedServices(cancellationTicket: CancellationTicket | null): Promise<StreamingService[]>;

  /**
   * Fetch linked services.
   *
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns All broken services.
   *
   * Retrieves a list of all the user's broken services.
   */
  fetchBrokenServices(cancellationTicket: CancellationTicket | null): Promise<StreamingService[]>;

  /**
   * Fetch all streaming categories.
   *
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns All streaming categories.
   *
   * Retrieves a list of all streaming categories.
   */
  fetchCategories(cancellationTicket: CancellationTicket | null): Promise<StreamingCategory[]>;

  /**
   * Fetch streaming category contents.
   *
   * @param categories - The list of categories to fetch content for.
   * @param services - The list of streaming services to fetch content for.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @param serviceError - The optional error handler to call when a problem occurs accessing a service.
   * @param categoryServiceComplete - The optional completion handler to call when retrieving content for a single category and service.
   * @returns Complete fetched content for all services and all categories or an empty array if {@link categoryServiceComplete} is non-null.
   *
   * Retrieves the content for the {@link categories} and {@link services} specified.
   * {@link serviceError} will be called when a service error occurs.
   * {@link categoryServiceComplete} will be called with the results for a single category-service pair. Specifying this callback will cause the response promise to resolve with an empty array for performance.
   */
  fetchContent(categories: StreamingCategory[], services: StreamingService[], cancellationTicket: CancellationTicket | null, serviceError: ((service: StreamingService, error: Error) => void) | null, categoryServiceComplete: ((category: StreamingCategory, service: StreamingService, content: StreamingContent[], error: Error | null) => void) | null): Promise<FetchContentCategoryResult[]>;

  /**
   * Fetch content children.
   *
   * @param content - The parent content item to fetch children for.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns The children of the requested {@link content} item.
   *
   * Retrieves the children of the given parent {@link content} object. The {@link content} object must be a folder to have children.
   *
   * Examples include using a {@link content} that represents a series where the children would be its seasons, or a {@link content} that represents a season where the children would be its episodes.
   */
  fetchContentChildren(content: StreamingContent, cancellationTicket: CancellationTicket | null): Promise<StreamingContent[]>;

  /**
   * Fetch content details.
   *
   * @param content - The content item to retrieve details for.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns The details of the requested {@link content} item.
   *
   * Retrieves {@link content} details.
   *
   * Details or additional metadata properties currently include browse path and estimated durations.
   */
  fetchContentDetails(content: StreamingContent, cancellationTicket: CancellationTicket | null): Promise<StreamingContentDetails>;

  /**
   * Search a streaming service.
   *
   * @param services - The streaming service to search.
   * @param searchPhrase - The list of streaming services to fetch content for.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @param serviceComplete - The optional completion handler to call when retrieving search results for a single service.
   * @returns Complete search results for all services or an empty array if {@link serviceComplete} is non-null.
   *
   * Searches the given {@link services} with the specified {@link searchPhrase}.
   * {@link serviceComplete} will be called with the results for a single service. Specifying this callback will cause the response promise to resolve with an empty array for performance.
   */
  searchContent(services: StreamingService[], searchPhrase: string, cancellationTicket: CancellationTicket | null, serviceComplete: ((service: StreamingService, content: StreamingContent[], error: Error | null) => void) | null): Promise<SearchContentServiceResult[]>;

  /**
   * Fetch an image.
   *
   * @param url - The image url to fetch.
   * @param cancellationTicket - The ticket to manually cancel the operation.
   * @returns Image data as a base64 string.
   *
   * Retrieves the image from the specified image {@link url}.
   *
   * Directly fetching image urls in any other way can lead to missing data or 404s depending on the streaming service.
   */
  fetchImage(url: string, cancellationTicket: CancellationTicket | null): Promise<string>;
}

type BridgeInvoker<T> = () => T;
type BridgeResultHandler = (res: any) => any;

/**
 * Younify Connect SDK main object.
 *
 * Use the {@link shared} singleton instance to interact with the sdk. Before the sdk can be meaningfully used {@link configure} must be called.
 */
export class Connect implements Connect
{
  private static instance: Connect;

  public static get shared(): Connect
  {
    if (!Connect.instance)
      Connect.instance = new Connect();

    return Connect.instance;
  }

  private _logListener: LogListener | null = null;
  private _logListenerSubscriptions: EmitterSubscription[] = new Array<EmitterSubscription>;
  private _tokenHandler: TokenHandler | null = null;
  private _tokenHandlerSubscriptions: EmitterSubscription[] = new Array<EmitterSubscription>;
  private _nextRequestId: number = 0;

  private constructor()
  {
  }

  private async callBridge<T>(f: BridgeInvoker<T>, h: BridgeResultHandler): Promise<T>
  {
    try
    {
      let res = await f();
      let hres = h(res);
      return hres;
    }
    catch (e: any)
    {
      let userInfo = (e as Error)?.userInfo as ErrorUserInfoPartialResult;
      let res = userInfo?.partialResult;
      if (res)
      {
        let pres = h(res);
        userInfo.partialResult = pres;
      }

      throw e;
    }
  }

  private updateService(target: StreamingService | null | undefined, source: StreamingService | null | undefined)
  {
    if (target && source)
      Object.assign(target, source);
  }

  private checkCancellationTicketAndGetId(cancellationTicket: CancellationTicket | null): string
  {
    cancellationTicket = cancellationTicket || CancellationTicket.none;
    cancellationTicket.throwIfCancellationRequested();
    return (cancellationTicket as unknown as CancellationTicketInternal).id;
  }

  public configure(options: ConnectOptions): Promise<void>
  {
    const logListener = options.logListener;
    if (this._logListener != logListener)
    {
      while (this._logListenerSubscriptions.length > 0)
        this._logListenerSubscriptions.pop()?.remove();

      this._logListener = logListener ?? null;
      if (logListener)
        this._logListenerSubscriptions.push(connectEvt.addListener("Log_onLog", (evt) =>
        {
          logListener.onMessage(evt.level, evt.message);
        }));
    }

    const tokenHandler = options.tokenHandler;
    if (this._tokenHandler != tokenHandler)
    {
      while (this._tokenHandlerSubscriptions.length > 0)
        this._tokenHandlerSubscriptions.pop()?.remove();

      this._tokenHandler = tokenHandler ?? null;
      if (tokenHandler)
      {
        this._tokenHandlerSubscriptions.push(connectEvt.addListener("Token_onRenewTokens", (evt) =>
        {
          const renewalId = evt.renewalId;
          const renewed = (newAccessToken: string | null, newRefreshToken: string | null): void =>
          {
            YounifyConnectSDK.completeTokenRenewal(renewalId, newAccessToken, newRefreshToken);
          };

          tokenHandler.onRenew(evt.expiredAccessToken, evt.refreshToken, renewed);
        }));

        this._tokenHandlerSubscriptions.push(connectEvt.addListener("Token_onRenewedTokens", (evt) =>
        {
          tokenHandler.onRenewed(evt.newAccessToken, evt.newRefreshToken);
        }));
      }
    }

    return this.callBridge(
      () => YounifyConnectSDK.configure({ ...options }),
      (res) => res
    );
  }

  public setUserTokens(accessToken: string | null, refreshToken: string | null): Promise<void>
  {
    return this.callBridge(
      () => YounifyConnectSDK.setUserTokens(accessToken, refreshToken),
      (res) => res
    )
  }

  public requestUserConsent(cancellationTicket: CancellationTicket | null): Promise<boolean>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.requestUserConsent(cancellationTicketId),
      (res) => res
    );
  }

  public linkService(service: StreamingService, cancellationTicket: CancellationTicket | null): Promise<boolean>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.linkService({ ...service }, cancellationTicketId),
      (res) =>
      {
        let linkServiceResult = LinkServiceResultFactory.createFromJsonObj(res);
        this.updateService(service, linkServiceResult.service);
        return linkServiceResult.success;
      }
    );
  }

  public manageLinkedService(service: StreamingService, cancellationTicket: CancellationTicket | null): Promise<void>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.manageLinkedService({ ...service }, cancellationTicketId),
      (res) =>
      {
        let updatedService = StreamingServiceFactory.createFromJsonObj(res);
        this.updateService(service, updatedService);
        return null;
      }
    );
  }

  public unlinkService(service: StreamingService, cancellationTicket: CancellationTicket | null): Promise<void>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.unlinkService({ ...service }, cancellationTicketId),
      (res) =>
      {
        let updatedService = StreamingServiceFactory.createFromJsonObj(res);
        this.updateService(service, updatedService);
        return null;
      }
    );
  }

  public fetchServices(cancellationTicket: CancellationTicket | null): Promise<StreamingService[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchServices(cancellationTicketId),
      (res) => StreamingServiceFactory.createFromJsonArray(res)
    );
  }

  public fetchLinkedServices(cancellationTicket: CancellationTicket | null): Promise<StreamingService[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchLinkedServices(cancellationTicketId),
      (res) => StreamingServiceFactory.createFromJsonArray(res)
    );
  }

  public fetchBrokenServices(cancellationTicket: CancellationTicket | null): Promise<StreamingService[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchBrokenServices(cancellationTicketId),
      (res) => StreamingServiceFactory.createFromJsonArray(res)
    );
  }

  public fetchCategories(cancellationTicket: CancellationTicket | null): Promise<StreamingCategory[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchCategories(cancellationTicketId),
      (res) => StreamingCategoryFactory.createFromJsonArray(res)
    );
  }

  public async fetchContent(categories: StreamingCategory[], services: StreamingService[], cancellationTicket: CancellationTicket | null, serviceError: ((service: StreamingService, error: Error) => void) | null, categoryServiceComplete: ((category: StreamingCategory, service: StreamingService, content: StreamingContent[], error: Error | null) => void) | null): Promise<FetchContentCategoryResult[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    const requestId = (this._nextRequestId++).toString() + '-' + Date.now().toString(36);
    const subscriptions = new Array<EmitterSubscription>();

    const updateService = (sourceService: StreamingService): StreamingService =>
    {
      let targetService = services.find(obj => obj.name == sourceService.name);
      this.updateService(targetService, sourceService);
      return targetService ?? StreamingServiceFactory.createFromJsonObj(sourceService);
    };

    const updateServices = (res: FetchContentCategoryResult[]) =>
    {
      res.forEach(result =>
      {
        result.services.forEach((resultService) =>
        {
          resultService.service = updateService(resultService.service);
        });
      });
    };

    let serviceErrors: boolean = false;
    let categoryServiceCompletions: boolean = false;
    let allCompletions: boolean = true;

    if (serviceError)
    {
      subscriptions.push(connectEvt.addListener("FetchContent_onServiceError", (evt) =>
      {
        let evtCategoryServiceError = EventCategoryServiceErrorFactory.createFromJsonObj(evt);
        if (evtCategoryServiceError.requestId == requestId)
          serviceError(updateService(evtCategoryServiceError.service), evtCategoryServiceError.error);
      }));
      serviceErrors = true;
    }

    if (categoryServiceComplete)
    {
      subscriptions.push(connectEvt.addListener("FetchContent_onCategoryServiceComplete", (evt) =>
      {
        let evtCategoryServiceComplete = EventCategoryServiceCompleteFactory.createFromJsonObj(evt);
        if (evtCategoryServiceComplete.requestId == requestId)
          categoryServiceComplete(evtCategoryServiceComplete.category, updateService(evtCategoryServiceComplete.service), evtCategoryServiceComplete.content, evtCategoryServiceComplete.error);
      }));
      categoryServiceCompletions = true;
      allCompletions = false;
    }

    try
    {
      return await this.callBridge(
        () => YounifyConnectSDK.fetchContent(requestId, [ ...categories ].map(c => ({ ...c })), [ ...services ].map(s => ({ ...s })), cancellationTicketId, serviceErrors, categoryServiceCompletions, allCompletions),
        (res) =>
        {
          let categoryResults = FetchContentCategoryResultFactory.createFromJsonArray(res);
          updateServices(categoryResults);
          return categoryResults;
        }
      );
    }
    finally
    {
      while (subscriptions.length > 0)
        subscriptions.pop()?.remove();
    }
  }

  public fetchContentChildren(content: StreamingContent, cancellationTicket: CancellationTicket | null): Promise<StreamingContent[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchContentChildren({ ...content }, cancellationTicketId),
      (res) => StreamingContentFactory.createFromJsonArray(res)
    );
  }

  public fetchContentDetails(content: StreamingContent, cancellationTicket: CancellationTicket | null): Promise<StreamingContentDetails>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchContentDetails({ ...content }, cancellationTicketId),
      (res) => StreamingContentDetailsFactory.createFromJsonObj(res)
    );
  }

  public async searchContent(services: StreamingService[], searchPhrase: string, cancellationTicket: CancellationTicket | null, serviceComplete: ((service: StreamingService, content: StreamingContent[], error: Error | null) => void) | null): Promise<SearchContentServiceResult[]>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    const requestId = (this._nextRequestId++).toString() + '-' + Date.now().toString(36);
    const subscriptions = new Array<EmitterSubscription>();

    const updateService = (sourceService: StreamingService): StreamingService =>
    {
      let targetService = services.find(obj => obj.name == sourceService.name);
      this.updateService(targetService, sourceService);
      return targetService ?? StreamingServiceFactory.createFromJsonObj(sourceService);
    };

    const updateServices = (res: SearchContentServiceResult[]) =>
    {
      res.forEach((serviceResult) =>
      {
        serviceResult.service = updateService(serviceResult.service);
      });
    };

    let serviceCompletions: boolean = false;
    let allCompletions: boolean = true;

    if (serviceComplete)
    {
      subscriptions.push(connectEvt.addListener("SearchContent_onServiceComplete", (evt) =>
      {
        let evtServiceComplete = EventSearchContentServiceCompleteFactory.createFromJsonObj(evt);
        if (evtServiceComplete.requestId == requestId)
          serviceComplete(updateService(evtServiceComplete.service), evtServiceComplete.content, evtServiceComplete.error);
      }));
      serviceCompletions = true;
      allCompletions = false;
    }

    try
    {
      return await this.callBridge(
        () => YounifyConnectSDK.searchContent(requestId, [ ...services ].map(s => ({ ...s })), searchPhrase, cancellationTicketId, serviceCompletions, allCompletions),
        (res) =>
        {
          let searchResults = SearchContentServiceResultFactory.createFromJsonArray(res);
          updateServices(searchResults);
          return searchResults;
        }
      );
    }
    finally
    {
      while (subscriptions.length > 0)
        subscriptions.pop()?.remove();
    }
  }

  public fetchImage(url: string, cancellationTicket: CancellationTicket | null): Promise<string>
  {
    const cancellationTicketId = this.checkCancellationTicketAndGetId(cancellationTicket);

    return this.callBridge(
      () => YounifyConnectSDK.fetchImage(url, cancellationTicketId),
      (res) => res
    );
  }
}
