/**
 * Microsoft Graph API Client
 * Client credentials flow for application permissions
 * Reports.Read.All, User.Read.All, Calendars.Read
 */

export interface GraphUser {
  id: string;
  displayName: string | null;
  mail: string | null;
  userPrincipalName: string | null;
  jobTitle: string | null;
  department: string | null;
  accountEnabled: boolean | null;
}

export interface TeamsActivityRow {
  userPrincipalName: string;
  lastActivityDate: string;
  teamChatMessageCount: number;
  privateChatMessageCount: number;
  callCount: number;
  meetingCount: number;
  meetingsOrganizedCount: number;
  meetingsAttendedCount: number;
  audioDurationSeconds: number;
}

export interface EmailActivityRow {
  userPrincipalName: string;
  lastActivityDate: string;
  sendCount: number;
  receiveCount: number;
  readCount: number;
}

export interface UserMessage {
  createdDateTime: string;
  fromUserId: string | null;
}

export interface CalendarEvent {
  id: string;
  subject: string;
  start: { dateTime: string; timeZone: string };
  end: { dateTime: string; timeZone: string };
  attendees: Array<{
    emailAddress: { address: string; name: string };
    type: string;
  }>;
  isOnlineMeeting: boolean;
  isCancelled: boolean;
}

export interface MsGraphClientConfig {
  tenantId: string;
  clientId: string;
  clientSecret: string;
}

export class MsGraphClient {
  private config: MsGraphClientConfig;
  private accessToken: string | null = null;
  private tokenExpiry: number = 0;

  constructor(config: MsGraphClientConfig) {
    this.config = config;
  }

  private async getToken(): Promise<string> {
    if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
      return this.accessToken;
    }

    const url = `https://login.microsoftonline.com/${this.config.tenantId}/oauth2/v2.0/token`;
    const body = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      scope: 'https://graph.microsoft.com/.default',
    });

    const res = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: body.toString(),
    });

    if (!res.ok) {
      const text = await res.text();
      throw new Error(`Graph token request failed: ${res.status} ${text}`);
    }

    const data = await res.json();
    this.accessToken = data.access_token;
    this.tokenExpiry = Date.now() + data.expires_in * 1000;
    return this.accessToken!;
  }

  private async fetchJson<T>(path: string, retryCount = 0): Promise<T> {
    const token = await this.getToken();
    const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
      },
    });

    // Retry on 429 rate limit — respect Retry-After header (minimum 30s)
    if (res.status === 429 && retryCount < 4) {
      const retryAfter = Math.max(30, parseInt(res.headers.get('Retry-After') || '30', 10));
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      return this.fetchJson(path, retryCount + 1);
    }

    if (!res.ok) {
      const text = await res.text();
      throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
    }

    return res.json();
  }

  private async fetchCsv(path: string): Promise<string> {
    const token = await this.getToken();
    const res = await fetch(`https://graph.microsoft.com/v1.0${path}`, {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'text/csv',
      },
    });

    if (!res.ok) {
      const text = await res.text();
      throw new Error(`Graph API error ${res.status} for ${path}: ${text}`);
    }

    return res.text();
  }

  /**
   * Parse CSV — handles BOM prefix on first header column
   */
  private parseCsv(csv: string): Record<string, string>[] {
    const lines = csv.split('\n').filter(l => l.trim());
    if (lines.length < 2) return [];

    // Strip BOM from first header if present
    const rawHeaders = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '').replace(/^\uFEFF/, ''));
    const rows: Record<string, string>[] = [];

    for (let i = 1; i < lines.length; i++) {
      const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
      const row: Record<string, string> = {};
      rawHeaders.forEach((h, idx) => {
        row[h] = values[idx] ?? '';
      });
      rows.push(row);
    }

    return rows;
  }

  private parseInt0(v: string): number {
    const n = parseInt(v);
    return isNaN(n) ? 0 : n;
  }

  /**
   * Get all users in the tenant (paginated)
   */
  async getUsers(): Promise<GraphUser[]> {
    const users: GraphUser[] = [];
    let url: string | null = '/users?$select=id,displayName,mail,userPrincipalName,jobTitle,department,accountEnabled&$top=999';

    while (url) {
      const data: { value: GraphUser[]; '@odata.nextLink'?: string } = await this.fetchJson(url);
      users.push(...data.value);
      if (data['@odata.nextLink']) {
        url = data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '');
      } else {
        url = null;
      }
    }

    return users;
  }

  /**
   * Get the tenant's verified email domains (used to identify external attendees)
   */
  async getOrganizationDomains(): Promise<string[]> {
    try {
      const data = await this.fetchJson<{
        value: Array<{ verifiedDomains: Array<{ name: string; isDefault: boolean }> }>;
      }>('/organization?$select=verifiedDomains');
      return (data.value[0]?.verifiedDomains ?? []).map(d => d.name.toLowerCase());
    } catch {
      return [];
    }
  }

  /**
   * Get Teams user activity report — includes audio duration in seconds
   * period: 'D7' | 'D30' | 'D90'
   */
  async getTeamsActivity(period: string): Promise<TeamsActivityRow[]> {
    const csv = await this.fetchCsv(`/reports/getTeamsUserActivityUserDetail(period='${period}')`);
    const rows = this.parseCsv(csv);

    return rows
      .filter(r => r['User Principal Name'])
      .map(r => ({
        userPrincipalName: r['User Principal Name'] || '',
        lastActivityDate: r['Last Activity Date'] || '',
        teamChatMessageCount: this.parseInt0(r['Team Chat Message Count']),
        privateChatMessageCount: this.parseInt0(r['Private Chat Message Count']),
        callCount: this.parseInt0(r['Call Count']),
        meetingCount: this.parseInt0(r['Meeting Count']),
        meetingsOrganizedCount: this.parseInt0(r['Meetings Organized Count']),
        meetingsAttendedCount: this.parseInt0(r['Meetings Attended Count']),
        // "Audio Duration In Seconds" is the pre-computed seconds column
        audioDurationSeconds: this.parseInt0(r['Audio Duration In Seconds']),
      }));
  }

  /**
   * Get Email activity report
   * period: 'D7' | 'D30' | 'D90'
   */
  async getEmailActivity(period: string): Promise<EmailActivityRow[]> {
    const csv = await this.fetchCsv(`/reports/getEmailActivityUserDetail(period='${period}')`);
    const rows = this.parseCsv(csv);

    return rows
      .filter(r => r['User Principal Name'])
      .map(r => ({
        userPrincipalName: r['User Principal Name'] || '',
        lastActivityDate: r['Last Activity Date'] || '',
        sendCount: this.parseInt0(r['Send Count']),
        receiveCount: this.parseInt0(r['Receive Count']),
        readCount: this.parseInt0(r['Read Count']),
      }));
  }

  /**
   * Get messages sent by a user in a date range, across all their chats.
   * Requires Chat.Read.All application permission.
   *
   * Strategy (based on Graph API docs):
   *   1. GET /users/{id}/chats with $expand=lastMessagePreview to find recently active chats.
   *      Stop paging when the chat's last message preview is older than startDate.
   *   2. For each recent chat, GET /chats/{id}/messages with:
   *      $filter=lastModifiedDateTime gt {start} and lastModifiedDateTime lt {end}
   *      $orderby=lastModifiedDateTime desc  (newest first)
   *
   * Returns only messages sent by this user (from.user.id match).
   */
  async getUserMessages(
    userId: string,
    startDate: Date,
    endDate: Date
  ): Promise<UserMessage[]> {
    const messages: UserMessage[] = [];
    const startIso = startDate.toISOString().slice(0, 19) + 'Z';
    const endIso = endDate.toISOString().slice(0, 19) + 'Z';
    const startMs = startDate.getTime();

    // Step 1: get chats sorted by most recent activity, stop when too old
    const chatIds: string[] = [];
    let chatUrl: string | null =
      `/users/${encodeURIComponent(userId)}/chats` +
      `?$expand=lastMessagePreview&$orderby=lastMessagePreview/createdDateTime desc&$top=50`;

    while (chatUrl && chatIds.length < 200) {
      const data: {
        value: Array<{ id: string; lastMessagePreview?: { createdDateTime?: string } }>;
        '@odata.nextLink'?: string;
      } = await this.fetchJson(chatUrl);

      let hitOldChat = false;
      for (const chat of data.value) {
        const lastMsgTime = chat.lastMessagePreview?.createdDateTime
          ? new Date(chat.lastMessagePreview.createdDateTime).getTime()
          : null;
        if (lastMsgTime !== null && lastMsgTime < startMs) {
          hitOldChat = true;
          break;
        }
        chatIds.push(chat.id);
      }

      chatUrl = hitOldChat
        ? null
        : data['@odata.nextLink']
          ? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
          : null;
    }

    // Step 2: for each recent chat, get messages in the date range
    for (const chatId of chatIds) {
      let msgUrl: string | null =
        `/chats/${encodeURIComponent(chatId)}/messages` +
        `?$filter=lastModifiedDateTime gt ${startIso} and lastModifiedDateTime lt ${endIso}` +
        `&$orderby=lastModifiedDateTime desc&$top=50`;

      let pageCount = 0;
      try {
        while (msgUrl && pageCount < 20) {
          const data: {
            value: Array<{ createdDateTime: string; from?: { user?: { id: string } } }>;
            '@odata.nextLink'?: string;
          } = await this.fetchJson(msgUrl);

          pageCount++;
          for (const msg of data.value) {
            if (msg.from?.user?.id !== userId) continue;
            messages.push({ createdDateTime: msg.createdDateTime, fromUserId: userId });
          }

          msgUrl = data['@odata.nextLink']
            ? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
            : null;
        }
      } catch {
        // Skip inaccessible chats (403 on meeting threads, 429 exhausted, etc.)
      }
    }

    return messages;
  }

  /**
   * POST /users/{user}/messages — search by sender and/or subject in a date range.
   * Requires Mail.ReadWrite application permission.
   */
  async searchMailboxMessages(options: {
    userEmail: string;
    fromAddress?: string;
    subject?: string;
    maxResults?: number;
  }): Promise<{ id: string; subject: string; from: string; receivedDateTime: string; isRead: boolean }[]> {
    // Exchange Online requires KQL $search for nested properties like from/emailAddress/address
    const kqlParts: string[] = [];
    if (options.fromAddress) kqlParts.push(`from:${options.fromAddress}`);
    if (options.subject) kqlParts.push(`subject:${options.subject}`);
    if (!kqlParts.length) throw new Error('At least fromAddress or subject required');

    const top = Math.min(options.maxResults ?? 50, 100);
    const searchVal = `"${kqlParts.join(' ')}"`;
    const qs = `$search=${encodeURIComponent(searchVal)}&$select=id,subject,from,receivedDateTime,isRead&$top=${top}`;
    const url = `/users/${encodeURIComponent(options.userEmail)}/messages?${qs}`;

    const token = await this.getToken();
    const res = await fetch(`https://graph.microsoft.com/v1.0${url}`, {
      headers: {
        Authorization: `Bearer ${token}`,
        Accept: 'application/json',
        ConsistencyLevel: 'eventual',
      },
    });
    if (!res.ok) {
      const text = await res.text();
      throw new Error(`Graph API error ${res.status}: ${text}`);
    }
    const data = await res.json();
    return (data.value ?? []).map((m: any) => ({
      id: m.id,
      subject: m.subject ?? '',
      from: m.from?.emailAddress?.address ?? '',
      receivedDateTime: m.receivedDateTime ?? '',
      isRead: m.isRead ?? false,
    }));
  }

  /**
   * DELETE /users/{user}/messages/{messageId}
   * Permanently deletes a message. Requires Mail.ReadWrite application permission.
   */
  async deleteMailboxMessage(userEmail: string, messageId: string): Promise<void> {
    const token = await this.getToken();
    const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}`;
    const res = await fetch(url, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${token}` },
    });
    if (res.status === 204) return;
    if (res.status === 404) return; // already gone
    const text = await res.text();
    throw new Error(`Graph delete failed ${res.status}: ${text}`);
  }

  /**
   * Move a message to Deleted Items (soft delete — recoverable).
   * Requires Mail.ReadWrite application permission.
   */
  async moveToDeletedItems(userEmail: string, messageId: string): Promise<void> {
    const token = await this.getToken();
    const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/messages/${encodeURIComponent(messageId)}/move`;
    const res = await fetch(url, {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ destinationId: 'deleteditems' }),
    });
    if (res.ok) return;
    if (res.status === 404) return;
    const text = await res.text();
    throw new Error(`Graph move failed ${res.status}: ${text}`);
  }

  /**
   * Fetch the raw photo bytes for a Microsoft Graph user.
   * Returns { bytes, contentType } on 200, null when the user has no photo (404),
   * and throws on any other non-2xx response so the caller can map to 502/503.
   *
   * Does NOT reuse fetchJson (which is JSON-only); issues its own fetch for binary data.
   * No retry logic — photo fetches are best-effort per D-26.
   */
  async getUserPhotoBytes(userId: string): Promise<{ bytes: ArrayBuffer; contentType: string } | null> {
    const token = await this.getToken();
    const url = `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userId)}/photo/$value`;
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (res.status === 404) {
      return null;
    }

    if (!res.ok) {
      const text = await res.text().catch(() => '');
      throw new Error(`Graph photo error ${res.status} for user ${userId}: ${text}`);
    }

    const bytes = await res.arrayBuffer();
    const contentType = res.headers.get('content-type') ?? 'image/jpeg';
    return { bytes, contentType };
  }

  /**
   * Get calendar events for a user in a date range (paginated).
   * Returns empty array and logs if the mailbox is not Exchange Online (graceful degradation).
   */
  async getUserCalendarEvents(
    userId: string,
    startDate: Date,
    endDate: Date
  ): Promise<CalendarEvent[]> {
    const start = startDate.toISOString();
    const end = endDate.toISOString();
    const events: CalendarEvent[] = [];

    let url: string | null =
      `/users/${encodeURIComponent(userId)}/calendarView` +
      `?startDateTime=${start}&endDateTime=${end}` +
      `&$select=id,subject,start,end,attendees,isOnlineMeeting,isCancelled` +
      `&$top=100`;

    while (url) {
      try {
        const data: { value: CalendarEvent[]; '@odata.nextLink'?: string } =
          await this.fetchJson(url);
        events.push(...data.value.filter(e => !e.isCancelled));
        url = data['@odata.nextLink']
          ? data['@odata.nextLink'].replace('https://graph.microsoft.com/v1.0', '')
          : null;
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        // Guests and on-prem mailboxes don't support REST API — skip silently
        if (msg.includes('MailboxNotEnabledForRESTAPI') || msg.includes('404')) {
          break;
        }
        throw err;
      }
    }

    return events;
  }
}
