/**
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

import { v4 as uuid } from 'uuid'

import { Activity, Attachment, ConversationAccount } from '@microsoft/agents-activity'
import { Observable, BehaviorSubject, type Subscriber } from 'rxjs'

import { CopilotStudioClient } from './copilotStudioClient'
import { debug, trace } from '@microsoft/agents-telemetry'
import { CopilotStudioClientTraceDefinitions } from './observability'

const logger = debug('copilot-studio:webchat')

/**
 * Configuration settings for the Copilot Studio WebChat connection.
 * These settings control the behavior and appearance of the WebChat interface
 * when connected to the Copilot Studio service.
 */
export interface CopilotStudioWebChatSettings {
  /**
   * Whether to show typing indicators in the WebChat when the agent is processing a response.
   * When enabled, users will see a typing indicator while waiting for the agent's reply,
   * providing visual feedback that their message is being processed.
   * @default false
   */
  showTyping?: boolean;

  /**
   * An existing conversation ID to resume. When provided, the connection will
   * send subsequent messages to this conversation instead of starting a new one.
   *
   * By default, providing a conversationId will skip the initial
   * `startConversationStreaming()` call. Override this with the
   * `startConversation` setting.
   *
   * **Note:** The server does not validate conversation IDs. A non-existent
   * GUID will silently create a new conversation under that ID, while a
   * non-GUID string may cause the server to return no response. Only pass
   * IDs that were previously captured from a real conversation.
   */
  conversationId?: string;

  /**
   * Controls whether `startConversationStreaming()` is called when the
   * connection is first subscribed to.
   *
   * - `undefined` (default): starts a new conversation only when no
   *   `conversationId` is provided (`!conversationId`).
   * - `true`: always starts a conversation, even when resuming.
   * - `false`: never starts a conversation, even for new connections.
   */
  startConversation?: boolean;
}

/**
 * Represents a connection interface for integrating Copilot Studio with WebChat.
 *
 * @remarks
 * This interface provides the necessary methods and observables to facilitate
 * bidirectional communication between a WebChat client and the Copilot Studio service.
 *
 * The connection follows the DirectLine protocol pattern, making it compatible with
 * Microsoft Bot Framework WebChat components.
 */
export interface CopilotStudioWebChatConnection {
  /**
   * An observable that emits the current connection status as numeric values.
   * This allows WebChat clients to monitor and react to connection state changes.
   *
   * Connection status values:
   * - 0: Disconnected - No active connection to the service
   * - 1: Connecting - Attempting to establish connection
   * - 2: Connected - Successfully connected and ready for communication
   */
  connectionStatus$: BehaviorSubject<number>;

  /**
   * An observable stream that emits incoming activities from the Copilot Studio service.
   * Each activity represents a message, card, or other interactive element sent by the agent.
   *
   * All emitted activities include:
   * - A timestamp indicating when the activity was received
   * - A 'webchat:sequence-id' in their channelData for proper message ordering
   * - Standard Bot Framework Activity properties (type, text, attachments, etc.)
   */
  activity$: Observable<Partial<Activity>>;

  /**
   * The active conversation ID. Set from `CopilotStudioWebChatSettings.conversationId`
   * when resuming, or captured from the first response activity for new conversations.
   * Returns `undefined` until a conversation has been established.
   */
  readonly conversationId: string | undefined;

  /**
   * Posts a user activity to the Copilot Studio service and returns an observable
   * that emits the activity ID once the message is successfully sent.
   *
   * The method validates that the activity contains meaningful content and handles
   * the complete message flow including optional typing indicators.
   *
   * @param activity - The user activity to send.
   * @returns An observable that emits the unique activity ID upon successful posting.
   * @throws Error if the activity text is empty or if the connection is not properly initialized.
   */
  postActivity(activity: Activity): Observable<string>;

  /**
   * Gracefully terminates the connection to the Copilot Studio service.
   * This method ensures proper cleanup by completing all active observables
   * and releasing associated resources.
   *
   * After calling this method:
   * - The connectionStatus$ observable will be completed
   * - The activity$ observable will stop emitting new activities
   * - No further activities can be posted through this connection
   */
  end(): void;
}

/**
 * Creates a wrapper that invokes `fn` at most once.
 * On the first call the wrapper invokes `fn(value)` and returns whatever `fn` returns.
 * Subsequent calls do nothing and return `undefined`.
 *
 * @template T - Type of the single argument passed to the wrapped function.
 * @param fn Function to be invoked once.
 * @returns A wrapper function that calls `fn` at most once.
 */
function once<T = void> (fn: (value: T) => Promise<void>): (value: T) => Promise<void> | void {
  let called = false

  return value => {
    if (!called) {
      called = true

      return fn(value)
    }
  }
}

/**
 * A utility class that provides WebChat integration capabilities for Copilot Studio services.
 *
 * @remarks
 * This class acts as a bridge between Microsoft Bot Framework WebChat and Copilot Studio,
 * enabling seamless communication through a DirectLine-compatible interface.
 *
 * ## Key Features:
 * - DirectLine protocol compatibility for easy WebChat integration
 * - Real-time bidirectional messaging with Copilot Studio agents
 * - Automatic conversation management and message sequencing
 * - Optional typing indicators for enhanced user experience
 * - Observable-based architecture for reactive programming patterns
 *
 * ## Usage Scenarios:
 * - Embedding Copilot Studio agents in web applications
 * - Creating custom chat interfaces with WebChat components
 * - Building conversational AI experiences with Microsoft's bot ecosystem
 *
 * @example Basic WebChat Integration
 * ```typescript
 * import { CopilotStudioClient } from '@microsoft/agents-copilotstudio-client';
 * import { CopilotStudioWebChat } from '@microsoft/agents-copilotstudio-client';
 *
 * // Initialize the Copilot Studio client
 * const client = new CopilotStudioClient({
 *   botId: 'your-bot-id',
 *   tenantId: 'your-tenant-id'
 * });
 *
 * // Create a WebChat-compatible connection
 * const directLine = CopilotStudioWebChat.createConnection(client, {
 *   showTyping: true
 * });
 *
 * // Integrate with WebChat
 * window.WebChat.renderWebChat({
 *   directLine: directLine,
 *   // ... other WebChat options
 * }, document.getElementById('webchat'));
 * ```
 *
 * @example Advanced Usage with Connection Monitoring
 * ```typescript
 * const connection = CopilotStudioWebChat.createConnection(client);
 *
 * // Monitor connection status
 * connection.connectionStatus$.subscribe(status => {
 *   switch (status) {
 *     case 0: console.log('Disconnected'); break;
 *     case 1: console.log('Connecting...'); break;
 *     case 2: console.log('Connected and ready'); break;
 *   }
 * });
 *
 * // Listen for incoming activities
 * connection.activity$.subscribe(activity => {
 *   console.log('Received activity:', activity);
 * });
 * ```
 */
export class CopilotStudioWebChat {
  /**
   * Creates a DirectLine-compatible connection for integrating Copilot Studio with WebChat.
   *

   * @param client - A configured CopilotStudioClient instance that handles the underlying
   *                 communication with the Copilot Studio service. This client should be
   *                 properly authenticated and configured with the target bot details.
   *
   * @param settings - Optional configuration settings that control the behavior of the
   *                   WebChat connection. These settings allow customization of features
   *                   like typing indicators and other user experience enhancements.
   *
   * @returns A new CopilotStudioWebChatConnection instance that can be passed directly
   *          to WebChat's renderWebChat function as the directLine parameter. The
   *          connection is immediately ready for use and will automatically manage
   *          the conversation lifecycle.
   *
   * @throws Error if the provided client is not properly configured or if there are
   *         issues establishing the initial connection to the Copilot Studio service.
   *
   * @remarks
   * This method establishes a real-time communication channel between WebChat and the
   * Copilot Studio service. The returned connection object implements the DirectLine
   * protocol, making it fully compatible with Microsoft Bot Framework WebChat components.
   *
   * ## Connection Lifecycle:
   * 1. **Initialization**: Creates observables for connection status and activity streaming
   * 2. **Conversation Start**: Automatically initiates conversation when first activity is posted
   * 3. **Message Flow**: Handles bidirectional message exchange with proper sequencing
   * 4. **Cleanup**: Provides graceful connection termination
   *
   * ## Message Processing:
   * - User messages are validated and sent to Copilot Studio
   * - Agent responses are received and formatted for WebChat
   * - All activities include timestamps and sequence IDs for proper ordering
   * - Optional typing indicators provide visual feedback during processing
   *
   * @example
   * ```typescript
   * const connection = CopilotStudioWebChat.createConnection(client, {
   *   showTyping: true
   * });
   *
   * // Use with WebChat
   * window.WebChat.renderWebChat({
   *   directLine: connection
   * }, document.getElementById('webchat'));
   * ```
   */
  static createConnection (
    client: CopilotStudioClient,
    settings?: CopilotStudioWebChatSettings
  ): CopilotStudioWebChatConnection {
    const managed = trace(CopilotStudioClientTraceDefinitions.createConnection)
    managed.record({ showTyping: settings?.showTyping })

    try {
      logger.info('--> Creating connection between Copilot Studio and WebChat ...')

      const normalizedConversationId =
      settings?.conversationId && settings.conversationId.trim() !== ''
        ? settings.conversationId.trim()
        : undefined
      const shouldStart = settings?.startConversation ?? !normalizedConversationId

      let sequence = 0
      let activitySubscriber: Subscriber<Partial<Activity>> | undefined
      let conversation: ConversationAccount | undefined
      let activeConversationId: string | undefined = normalizedConversationId
      let ended = false
      let started = false

      const connectionStatus$ = new BehaviorSubject(0)
      const activity$ = createObservable<Partial<Activity>>(async (subscriber) => {
        try {
          activitySubscriber = subscriber

          const handleAcknowledgementOnce = once(async (): Promise<void> => {
            connectionStatus$.next(2)
            await Promise.resolve() // Webchat requires an extra tick to process the connection status change
          })

          // When resuming (shouldStart === false), transition straight to connected
          if (!shouldStart || started) {
            await handleAcknowledgementOnce()
            return
          }
          started = true

          logger.debug('--> Connection established.')
          notifyTyping()

          for await (const activity of client.startConversationStreaming()) {
            delete activity.replyToId
            if (!conversation && activity.conversation) {
              conversation = activity.conversation
            }
            if (activity.conversation?.id) {
              activeConversationId = activity.conversation.id
            }
            await handleAcknowledgementOnce()
            notifyActivity(activity)
            managed.actions.receivedFromCopilot(activity)
          }
          // If no activities received from bot, we should still acknowledge.
          await handleAcknowledgementOnce()
        } catch (error) {
          throw managed.fail(error)
        } finally {
          managed.end()
        }
      })

      const notifyActivity = (activity: Partial<Activity>) => {
        const newActivity = {
          ...activity,
          timestamp: new Date().toISOString(),
          channelData: {
            ...activity.channelData,
            'webchat:sequence-id': sequence,
          },
        }
        sequence++
        logger.debug(`Notify '${newActivity.type}' activity to WebChat:`, newActivity)
        activitySubscriber?.next(newActivity)
      }

      const notifyTyping = () => {
        if (!settings?.showTyping) {
          return
        }

        const from = conversation
          ? { id: conversation.id, name: conversation.name }
          : { id: 'agent', name: 'Agent' }
        notifyActivity({ type: 'typing', from })
      }

      return {
        connectionStatus$,
        activity$,

        get conversationId () {
          return activeConversationId
        },

        postActivity (activity: Activity) {
          try {
            logger.info('--> Preparing to send activity to Copilot Studio ...')

            if (!activity) {
              throw new Error('Activity cannot be null.')
            }

            if (ended) {
              throw new Error('Connection has been ended.')
            }

            if (!activitySubscriber) {
              throw new Error('Activity subscriber is not initialized.')
            }

            const result = createObservable<string>(async (subscriber) => {
              try {
                logger.info('--> Sending activity to Copilot Studio ...')
                const newActivity = Activity.fromObject({
                  ...activity,
                  id: uuid(),
                  attachments: await processAttachments(activity)
                })

                notifyActivity(newActivity)
                managed.actions.sentToWebChat(newActivity)
                notifyTyping()

                // Notify WebChat immediately that the message was sent
                subscriber.next(newActivity.id!)

                // Stream the agent's response, passing activeConversationId for URL routing
                for await (const responseActivity of client.sendActivityStreaming(newActivity, activeConversationId)) {
                  if (!activeConversationId && responseActivity.conversation?.id) {
                    activeConversationId = responseActivity.conversation.id
                  }
                  notifyActivity(responseActivity)
                  managed.actions.receivedFromCopilot(responseActivity)
                  logger.info('<-- Activity received correctly from Copilot Studio.')
                }

                subscriber.complete()
              } catch (error) {
                logger.error('Error sending Activity to Copilot Studio:', error)
                subscriber.error(error)
                managed.fail(error)
              } finally {
                managed.end()
              }
            })

            return result
          } catch (error) {
            throw managed.fail(error)
          } finally {
            managed.end()
          }
        },

        end () {
          logger.info('--> Ending connection between Copilot Studio and WebChat ...')
          ended = true
          connectionStatus$.complete()
          if (activitySubscriber) {
            activitySubscriber.complete()
            activitySubscriber = undefined
          }
          // End the connection span
          managed.end()
        },
      }
    } catch (error) {
      throw managed.fail(error)
    } finally {
      managed.end()
    }
  }
}

/**
 * Processes activity attachments.
 * @param activity The activity to process for attachments.
 * @returns A promise that resolves to the activity with all attachments converted.
 */
async function processAttachments (activity: Activity): Promise<Attachment[]> {
  if (activity.type !== 'message' || !activity.attachments?.length) {
    return activity.attachments || []
  }

  const attachments: Attachment[] = []
  for (const attachment of activity.attachments) {
    const processed = await processBlobAttachment(attachment)
    attachments.push(processed)
  }

  return attachments
}

/**
 * Processes a blob attachment to convert its content URL to a data URL.
 * @param attachment The attachment to process.
 * @returns A promise that resolves to the processed attachment.
 */
async function processBlobAttachment (attachment: Attachment): Promise<Attachment> {
  let newContentUrl = attachment.contentUrl
  if (!newContentUrl?.startsWith('blob:')) {
    return attachment
  }

  try {
    const response = await fetch(newContentUrl)
    if (!response.ok) {
      throw new Error(`Failed to fetch blob URL: ${response.status} ${response.statusText}`)
    }

    const blob = await response.blob()
    const arrayBuffer = await blob.arrayBuffer()
    const base64 = arrayBufferToBase64(arrayBuffer)
    newContentUrl = `data:${blob.type};base64,${base64}`
  } catch (error) {
    newContentUrl = attachment.contentUrl
    logger.error('Error processing blob attachment:', newContentUrl, error)
  }

  return { ...attachment, contentUrl: newContentUrl }
}

/**
 * Converts an ArrayBuffer to a base64 string.
 * @param buffer The ArrayBuffer to convert.
 * @returns The base64 encoded string.
 */
function arrayBufferToBase64 (buffer: ArrayBuffer): string {
  // Node.js environment
  const BufferClass = typeof globalThis.Buffer === 'function' ? globalThis.Buffer : undefined
  if (BufferClass && typeof BufferClass.from === 'function') {
    return BufferClass.from(buffer).toString('base64')
  }

  // Browser environment
  let binary = ''
  for (const byte of new Uint8Array(buffer)) {
    binary += String.fromCharCode(byte)
  }
  return btoa(binary)
}

/**
 * Creates an RxJS Observable that wraps an asynchronous function execution.
 *
 * @typeParam T - The type of value that the observable will emit
 * @param fn - An asynchronous function that receives a Subscriber and performs
 *             the desired async operation. The function should call subscriber.next()
 *             with results and subscriber.complete() when finished.
 * @returns A new Observable that executes the provided function and emits its results
 *
 * @remarks
 * This utility function provides a clean way to convert async/await patterns
 * into Observable streams, enabling integration with reactive programming patterns
 * used throughout the WebChat connection implementation.
 *
 * The created Observable handles promise resolution and rejection automatically,
 * converting them to appropriate next/error signals for subscribers.
 *
 * @example
 * ```typescript
 * const dataObservable = createObservable<string>(async (subscriber) => {
 *   try {
 *     const result = await fetchData();
 *     subscriber.next(result);
 *     subscriber.complete();
 *   } catch (error) {
 *     subscriber.error(error);
 *   }
 * });
 * ```
 */
function createObservable<T> (fn: (subscriber: Subscriber<T>) => void): Observable<T> {
  return new Observable<T>((subscriber: Subscriber<T>) => {
    Promise.resolve(fn(subscriber)).catch((error) => subscriber.error(error))
  })
}
