// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#import <Foundation/Foundation.h>

#import "Crashlytics/Crashlytics/Controllers/FIRCLSMetricKitManager.h"

#if CLS_METRICKIT_SUPPORTED

#import "Crashlytics/Crashlytics/Controllers/FIRCLSManagerData.h"
#include "Crashlytics/Crashlytics/Handlers/FIRCLSMachException.h"
#include "Crashlytics/Crashlytics/Handlers/FIRCLSSignal.h"
#import "Crashlytics/Crashlytics/Helpers/FIRCLSCallStackTree.h"
#import "Crashlytics/Crashlytics/Helpers/FIRCLSFile.h"
#import "Crashlytics/Crashlytics/Helpers/FIRCLSLogger.h"
#import "Crashlytics/Crashlytics/Models/FIRCLSExecutionIdentifierModel.h"
#import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
#import "Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlytics.h"
#import "Crashlytics/Crashlytics/Public/FirebaseCrashlytics/FIRCrashlyticsReport.h"

@interface FIRCLSMetricKitManager ()

@property FBLPromise *metricKitDataAvailable;
@property FIRCLSExistingReportManager *existingReportManager;
@property FIRCLSFileManager *fileManager;
@property FIRCLSManagerData *managerData;
@property BOOL metricKitPromiseFulfilled;

@end

@implementation FIRCLSMetricKitManager

- (instancetype)initWithManagerData:(FIRCLSManagerData *)managerData
              existingReportManager:(FIRCLSExistingReportManager *)existingReportManager
                        fileManager:(FIRCLSFileManager *)fileManager {
  _existingReportManager = existingReportManager;
  _fileManager = fileManager;
  _managerData = managerData;
  _metricKitPromiseFulfilled = NO;
  return self;
}

/*
 * Registers the MetricKit manager to receive MetricKit reports by adding self to the
 * MXMetricManager subscribers. Also initializes the promise that we'll use to ensure that any
 * MetricKit report files are included in Crashylytics fatal reports. If no crash occurred on the
 * last run of the app, this promise is immediately resolved so that the upload of any nonfatal
 * events can proceed.
 */
- (void)registerMetricKitManager API_AVAILABLE(ios(14)) {
  [[MXMetricManager sharedManager] addSubscriber:self];
  self.metricKitDataAvailable = [FBLPromise pendingPromise];

  // If there was no crash on the last run of the app or there's no diagnostic report in the
  // MetricKit directory, then we aren't expecting a MetricKit diagnostic report and should resolve
  // the promise immediately. If MetricKit captured a fatal event and Crashlytics did not, then
  // we'll still process the MetricKit crash but won't upload it until the app restarts again.
  if (![self.fileManager didCrashOnPreviousExecution] ||
      ![self.fileManager metricKitDiagnosticFileExists]) {
    @synchronized(self) {
      [self fulfillMetricKitPromise];
    }
  }

  // If we haven't resolved this promise within three seconds, resolve it now so that we're not
  // waiting indefinitely for MetricKit payloads that won't arrive.
  dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC), self.managerData.dispatchQueue,
                 ^{
                   @synchronized(self) {
                     if (!self.metricKitPromiseFulfilled) {
                       FIRCLSDebugLog(@"Resolving MetricKit promise after three seconds");
                       [self fulfillMetricKitPromise];
                     }
                   }
                 });

  FIRCLSDebugLog(@"Finished registering metrickit manager");
}

/*
 * This method receives diagnostic payloads from MetricKit whenever a fatal or nonfatal MetricKit
 * event occurs. If a fatal event, this method will be called when the app restarts. Since we're
 * including a MetricKit report file in the Crashlytics report to be sent to the backend, we need
 * to make sure that we process the payloads and write the included information to file before
 * the report is sent up. If this method is called due to a nonfatal event, it will be called
 * immediately after the event. Since we send nonfatal events on the next run of the app, we can
 * write out the information but won't need to resolve the promise.
 */
- (void)didReceiveDiagnosticPayloads:(NSArray<MXDiagnosticPayload *> *)payloads
    API_AVAILABLE(ios(14)) {
  BOOL processedFatalPayload = NO;
  for (MXDiagnosticPayload *diagnosticPayload in payloads) {
    if (!diagnosticPayload) {
      continue;
    }

    BOOL processedPayload = [self processMetricKitPayload:diagnosticPayload
                                           skipCrashEvent:processedFatalPayload];
    if (processedPayload && ([diagnosticPayload.crashDiagnostics count] > 0)) {
      processedFatalPayload = YES;
    }
  }
  // Once we've processed all the payloads, resolve the promise so that reporting uploading
  // continues. If there was not a crash on the previous run of the app, the promise will already
  // have been resolved.
  @synchronized(self) {
    [self fulfillMetricKitPromise];
  }
}

// Helper method to write a MetricKit payload's data to file.
- (BOOL)processMetricKitPayload:(MXDiagnosticPayload *)diagnosticPayload
                 skipCrashEvent:(BOOL)skipCrashEvent API_AVAILABLE(ios(14)) {
  BOOL writeFailed = NO;

  // Write out each type of diagnostic if it exists in the report
  BOOL hasCrash = [diagnosticPayload.crashDiagnostics count] > 0;
  BOOL hasHang = [diagnosticPayload.hangDiagnostics count] > 0;
  BOOL hasCPUException = [diagnosticPayload.cpuExceptionDiagnostics count] > 0;
  BOOL hasDiskWriteException = [diagnosticPayload.diskWriteExceptionDiagnostics count] > 0;

  // If there are no diagnostics in the report, return before writing out any files.
  if (!hasCrash && !hasHang && !hasCPUException && !hasDiskWriteException) {
    return false;
  }

  // MXDiagnosticPayload have both a timeStampBegin and timeStampEnd. Now that these events are
  // real-time, both refer to the same time - record both values anyway.
  NSTimeInterval beginSecondsSince1970 = [diagnosticPayload.timeStampBegin timeIntervalSince1970];
  NSTimeInterval endSecondsSince1970 = [diagnosticPayload.timeStampEnd timeIntervalSince1970];

  // Get file path for the active reports directory.
  NSString *activePath = [[self.fileManager activePath] stringByAppendingString:@"/"];

  // If there is a crash diagnostic in the payload, then this method was called for a fatal event.
  // Also ensure that there is a report from the last run of the app that we can write to.
  NSString *metricKitFatalReportFile;
  NSString *metricKitNonfatalReportFile;

  NSString *newestUnsentReportID =
      self.existingReportManager.newestUnsentReport.reportID
          ? [self.existingReportManager.newestUnsentReport.reportID stringByAppendingString:@"/"]
          : nil;
  NSString *currentReportID =
      [_managerData.executionIDModel.executionID stringByAppendingString:@"/"];
  BOOL crashlyticsFatalReported =
      ([diagnosticPayload.crashDiagnostics count] > 0) && (newestUnsentReportID != nil) &&
      ([self.fileManager
          fileExistsAtPath:[activePath stringByAppendingString:newestUnsentReportID]]);

  // Set the MetricKit fatal path appropriately depending on whether we also captured a Crashlytics
  // fatal event and whether the diagnostic report came from a fatal or nonfatal event.
  if (crashlyticsFatalReported) {
    metricKitFatalReportFile = [[activePath stringByAppendingString:newestUnsentReportID]
        stringByAppendingString:FIRCLSMetricKitFatalReportFile];
  } else {
    metricKitFatalReportFile = [[activePath stringByAppendingString:currentReportID]
        stringByAppendingString:FIRCLSMetricKitFatalReportFile];
  }
  metricKitNonfatalReportFile = [[activePath stringByAppendingString:currentReportID]
      stringByAppendingString:FIRCLSMetricKitNonfatalReportFile];

  if (!metricKitFatalReportFile || !metricKitNonfatalReportFile) {
    FIRCLSDebugLog(@"Error finding MetricKit files");
    return NO;
  }

  FIRCLSDebugLog(@"File paths for MetricKit report:  %@, %@", metricKitFatalReportFile,
                 metricKitNonfatalReportFile);
  if (hasCrash && ![_fileManager fileExistsAtPath:metricKitFatalReportFile]) {
    [_fileManager createFileAtPath:metricKitFatalReportFile contents:nil attributes:nil];
  }
  if ((hasHang | hasCPUException | hasDiskWriteException) &&
      ![_fileManager fileExistsAtPath:metricKitNonfatalReportFile]) {
    [_fileManager createFileAtPath:metricKitNonfatalReportFile contents:nil attributes:nil];
  }
  NSFileHandle *nonfatalFile =
      [NSFileHandle fileHandleForUpdatingAtPath:metricKitNonfatalReportFile];
  if ((hasHang | hasCPUException | hasDiskWriteException) && nonfatalFile == nil) {
    FIRCLSDebugLog(@"Unable to create or open nonfatal MetricKit file.");
    return false;
  }
  NSFileHandle *fatalFile = [NSFileHandle fileHandleForUpdatingAtPath:metricKitFatalReportFile];
  if (hasCrash && fatalFile == nil) {
    FIRCLSDebugLog(@"Unable to create or open fatal MetricKit file.");
    return false;
  }

  NSData *newLineData = [@"\n" dataUsingEncoding:NSUTF8StringEncoding];

  // For each diagnostic type, write out a section in the MetricKit report file. This section will
  // have subsections for threads, metadata, and event specific metadata.
  if (hasCrash && !skipCrashEvent) {
    // Write out time information to the MetricKit report file. Time needs to be a value for
    // backend serialization, so we write out end_time separately.

    MXCrashDiagnostic *crashDiagnostic = [diagnosticPayload.crashDiagnostics objectAtIndex:0];

    NSArray *threadArray = [self convertThreadsToArray:crashDiagnostic.callStackTree];
    NSDictionary *metadataDict = [self convertMetadataToDictionary:crashDiagnostic.metaData];

    NSString *nilString = @"";

    // On the backend, we process name, code name, and address into the subtitle of an issue.
    // Mach exception name and code should be preferred over signal name and code if available.
    const char *signalName = NULL;
    const char *signalCodeName = NULL;
    FIRCLSSignalNameLookup([crashDiagnostic.signal intValue], 0, &signalName, &signalCodeName);
    // signalName is the default name, so should never be NULL
    if (signalName == NULL) {
      signalName = "UNKNOWN";
    }
    if (signalCodeName == NULL) {
      signalCodeName = "";
    }

    const char *machExceptionName = NULL;
    const char *machExceptionCodeName = NULL;
#if CLS_MACH_EXCEPTION_SUPPORTED
    FIRCLSMachExceptionNameLookup(
        [crashDiagnostic.exceptionType intValue],
        (mach_exception_data_type_t)[crashDiagnostic.exceptionCode intValue], &machExceptionName,
        &machExceptionCodeName);
#endif
    if (machExceptionCodeName == NULL) {
      machExceptionCodeName = "";
    }

    NSString *name = machExceptionName != NULL ? [NSString stringWithUTF8String:machExceptionName]
                                               : [NSString stringWithUTF8String:signalName];
    NSString *codeName = machExceptionName != NULL
                             ? [NSString stringWithUTF8String:machExceptionCodeName]
                             : [NSString stringWithUTF8String:signalCodeName];

    NSDictionary *crashDictionary = @{
      @"metric_kit_fatal" : @{
        @"time" : [NSNumber numberWithLong:beginSecondsSince1970],
        @"end_time" : [NSNumber numberWithLong:endSecondsSince1970],
        @"metadata" : metadataDict,
        @"termination_reason" :
                (crashDiagnostic.terminationReason) ? crashDiagnostic.terminationReason : nilString,
        @"virtual_memory_region_info" : (crashDiagnostic.virtualMemoryRegionInfo)
            ? crashDiagnostic.virtualMemoryRegionInfo
            : nilString,
        @"exception_type" : crashDiagnostic.exceptionType,
        @"exception_code" : crashDiagnostic.exceptionCode,
        @"signal" : crashDiagnostic.signal,
        @"app_version" : crashDiagnostic.applicationVersion,
        @"code_name" : codeName,
        @"name" : name
      }
    };
    writeFailed = ![self writeDictionaryToFile:crashDictionary
                                          file:fatalFile
                                   newLineData:newLineData];
    writeFailed = writeFailed | ![self writeDictionaryToFile:@{@"threads" : threadArray}
                                                        file:fatalFile
                                                 newLineData:newLineData];
  }

  if (hasHang) {
    MXHangDiagnostic *hangDiagnostic = [diagnosticPayload.hangDiagnostics objectAtIndex:0];

    NSArray *threadArray = [self convertThreadsToArray:hangDiagnostic.callStackTree];
    NSDictionary *metadataDict = [self convertMetadataToDictionary:hangDiagnostic.metaData];

    NSDictionary *hangDictionary = @{
      @"exception" : @{
        @"type" : @"metrickit_nonfatal",
        @"name" : @"hang_event",
        @"time" : [NSNumber numberWithLong:beginSecondsSince1970],
        @"end_time" : [NSNumber numberWithLong:endSecondsSince1970],
        @"threads" : threadArray,
        @"metadata" : metadataDict,
        @"hang_duration" : [NSNumber numberWithDouble:[hangDiagnostic.hangDuration doubleValue]],
        @"app_version" : hangDiagnostic.applicationVersion
      }
    };

    writeFailed = ![self writeDictionaryToFile:hangDictionary
                                          file:nonfatalFile
                                   newLineData:newLineData];
  }

  if (hasCPUException) {
    MXCPUExceptionDiagnostic *cpuExceptionDiagnostic =
        [diagnosticPayload.cpuExceptionDiagnostics objectAtIndex:0];

    NSArray *threadArray = [self convertThreadsToArray:cpuExceptionDiagnostic.callStackTree];
    NSDictionary *metadataDict = [self convertMetadataToDictionary:cpuExceptionDiagnostic.metaData];

    NSDictionary *cpuDictionary = @{
      @"exception" : @{
        @"type" : @"metrickit_nonfatal",
        @"name" : @"cpu_exception_event",
        @"time" : [NSNumber numberWithLong:beginSecondsSince1970],
        @"end_time" : [NSNumber numberWithLong:endSecondsSince1970],
        @"threads" : threadArray,
        @"metadata" : metadataDict,
        @"total_cpu_time" :
            [NSNumber numberWithDouble:[cpuExceptionDiagnostic.totalCPUTime doubleValue]],
        @"total_sampled_time" :
            [NSNumber numberWithDouble:[cpuExceptionDiagnostic.totalSampledTime doubleValue]],
        @"app_version" : cpuExceptionDiagnostic.applicationVersion
      }
    };
    writeFailed = ![self writeDictionaryToFile:cpuDictionary
                                          file:nonfatalFile
                                   newLineData:newLineData];
  }

  if (hasDiskWriteException) {
    MXDiskWriteExceptionDiagnostic *diskWriteExceptionDiagnostic =
        [diagnosticPayload.diskWriteExceptionDiagnostics objectAtIndex:0];

    NSArray *threadArray = [self convertThreadsToArray:diskWriteExceptionDiagnostic.callStackTree];
    NSDictionary *metadataDict =
        [self convertMetadataToDictionary:diskWriteExceptionDiagnostic.metaData];

    NSDictionary *diskWriteDictionary = @{
      @"exception" : @{
        @"type" : @"metrickit_nonfatal",
        @"name" : @"disk_write_exception_event",
        @"time" : [NSNumber numberWithLong:beginSecondsSince1970],
        @"end_time" : [NSNumber numberWithLong:endSecondsSince1970],
        @"threads" : threadArray,
        @"metadata" : metadataDict,
        @"app_version" : diskWriteExceptionDiagnostic.applicationVersion,
        @"total_writes_caused" :
            [NSNumber numberWithDouble:[diskWriteExceptionDiagnostic.totalWritesCaused doubleValue]]
      }
    };
    writeFailed = ![self writeDictionaryToFile:diskWriteDictionary
                                          file:nonfatalFile
                                   newLineData:newLineData];
  }

  return !writeFailed;
}
/*
 * Required for MXMetricManager subscribers. Since we aren't currently collecting any MetricKit
 * metrics, this method is left empty.
 */
- (void)didReceiveMetricPayloads:(NSArray<MXMetricPayload *> *)payloads API_AVAILABLE(ios(13)) {
}

- (FBLPromise *)waitForMetricKitDataAvailable {
  FBLPromise *result = nil;
  @synchronized(self) {
    result = self.metricKitDataAvailable;
  }
  return result;
}

/*
 * Helper method to convert threads for a MetricKit fatal diagnostic event to an array of threads.
 */
- (NSArray *)convertThreadsToArray:(MXCallStackTree *)mxCallStackTree API_AVAILABLE(ios(14)) {
  FIRCLSCallStackTree *tree = [[FIRCLSCallStackTree alloc] initWithMXCallStackTree:mxCallStackTree];
  return [tree getArrayRepresentation];
}

/*
 * Helper method to convert threads for a MetricKit nonfatal diagnostic event to an array of frames.
 */
- (NSArray *)convertThreadsToArrayForNonfatal:(MXCallStackTree *)mxCallStackTree
    API_AVAILABLE(ios(14)) {
  FIRCLSCallStackTree *tree = [[FIRCLSCallStackTree alloc] initWithMXCallStackTree:mxCallStackTree];
  return [tree getFramesOfBlamedThread];
}

/*
 * Helper method to convert metadata for a MetricKit diagnostic event to a dictionary. MXMetadata
 * has a dictionaryRepresentation method but it is deprecated.
 */
- (NSDictionary *)convertMetadataToDictionary:(MXMetaData *)metadata API_AVAILABLE(ios(14)) {
  NSError *error = nil;
  NSDictionary *metadataDictionary =
      [NSJSONSerialization JSONObjectWithData:[metadata JSONRepresentation] options:0 error:&error];
  return metadataDictionary;
}

/*
 * Helper method to fulfill the metricKitDataAvailable promise and track that it has been fulfilled.
 */
- (void)fulfillMetricKitPromise {
  if (self.metricKitPromiseFulfilled) return;

  [self.metricKitDataAvailable fulfill:nil];
  self.metricKitPromiseFulfilled = YES;
}

/*
 * Helper method to write a dictionary of event information to file. Returns whether it succeeded.
 */
- (BOOL)writeDictionaryToFile:(NSDictionary *)dictionary
                         file:(NSFileHandle *)file
                  newLineData:(NSData *)newLineData {
  NSError *dataError = nil;
  NSData *data = [NSJSONSerialization dataWithJSONObject:dictionary options:0 error:&dataError];
  if (dataError) {
    FIRCLSDebugLog(@"Unable to write out dictionary.");
    return NO;
  }

  [file seekToEndOfFile];
  [file writeData:data];
  [file writeData:newLineData];

  return YES;
}

- (NSString *)getSignalName:(NSNumber *)signalCode {
  int signal = [signalCode intValue];
  switch (signal) {
    case SIGABRT:
      return @"SIGABRT";
    case SIGBUS:
      return @"SIGBUS";
    case SIGFPE:
      return @"SIGFPE";
    case SIGILL:
      return @"SIGILL";
    case SIGSEGV:
      return @"SIGSEGV";
    case SIGSYS:
      return @"SIGSYS";
    case SIGTRAP:
      return @"SIGTRAP";
    default:
      return @"UNKNOWN";
  }
  return @"UNKNOWN";
}

@end

#endif  // CLS_METRICKIT_SUPPORTED
