// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/testing/test_expectations.h"

#import <TargetConditionals.h>
#import <UIKit/UIKit.h>

#import "base/strings/sys_string_conversions.h"
#import "base/system/sys_info.h"
#import "build/build_config.h"
#import "ui/base/device_form_factor.h"

namespace {

// Message used by EarlGrey in `_XCTFailureHandler` when halting a test due to
// an assertion failure.
NSString* const kEarlGreyHaltExecutionMessage =
    @"Immediately halt execution of testcase";

// Exception name used by EarlGrey when a host application crash occurs.
NSString* const kEarlGreyInterruptExceptionName =
    @"EarlGreyInternalTestInterruptException";

}  // namespace

@implementation TestExpectationEntry

- (NSString*)expectedOutcomeDescription {
  NSMutableArray<NSString*>* outcomes = [NSMutableArray array];
  if (self.type & TestExpectationTypePass) {
    [outcomes addObject:@"pass"];
  }
  if (self.type & TestExpectationTypeFailure) {
    [outcomes addObject:@"fail"];
  }
  if (self.type & TestExpectationTypeCrash) {
    [outcomes addObject:@"crash"];
  }
  if (self.type & TestExpectationTypeSkip) {
    [outcomes addObject:@"skip"];
  }

  if (outcomes.count == 0) {
    return @"pass";
  }
  NSMutableString* result = [NSMutableString string];
  for (NSUInteger i = 0; i < outcomes.count; ++i) {
    if (i > 0) {
      if (i == outcomes.count - 1) {
        [result appendString:(outcomes.count > 2) ? @", or " : @" or "];
      } else {
        [result appendString:@", "];
      }
    }
    [result appendString:outcomes[i]];
  }
  return result;
}

// Returns a formatted string describing the line number and file path.
- (NSString*)locationDescription {
  if (self.filePath.length > 0) {
    return [NSString stringWithFormat:@"line %lu in %@",
                                      (unsigned long)self.lineNumber,
                                      self.filePath];
  }
  return
      [NSString stringWithFormat:@"line %lu", (unsigned long)self.lineNumber];
}

- (NSString*)documentationMessage {
  NSString* outcome = [self expectedOutcomeDescription];

  return [NSString stringWithFormat:@"This test is expected to %@ (%@).",
                                    outcome, [self locationDescription]];
}

- (NSString*)unmetExpectationMessageWithActualOutcome:(NSString*)actualOutcome {
  NSString* expectedOutcome = [self expectedOutcomeDescription];

  return [NSString
      stringWithFormat:@"Unmet test expectation (%@): expected %@, actual %@.",
                       [self locationDescription], expectedOutcome,
                       actualOutcome];
}

- (TestExpectationMatchResult)matchesIssueType:(XCTIssueType)issueType
                            compactDescription:(NSString*)compactDescription {
  if (issueType == XCTIssueTypeUnmatchedExpectedFailure) {
    return TestExpectationMatchResult::kUnmatched;
  }

  BOOL didCrash = (issueType == XCTIssueTypeUncaughtException ||
                   issueType == XCTIssueTypeThrownError);

  if (didCrash) {
    if ([compactDescription containsString:kEarlGreyHaltExecutionMessage] &&
        ![compactDescription containsString:kEarlGreyInterruptExceptionName]) {
      didCrash = NO;
    }
  }

  BOOL matches = didCrash ? ((self.type & TestExpectationTypeCrash) != 0)
                          : ((self.type & TestExpectationTypeFailure) != 0);
  if (!matches) {
    NSString* actualOutcome = didCrash ? @"Crash" : @"Failure";
    NSLog(@"%@", [self unmetExpectationMessageWithActualOutcome:actualOutcome]);
    return TestExpectationMatchResult::kMismatched;
  }
  return TestExpectationMatchResult::kMatched;
}

@end

@interface TestExpectations ()
- (instancetype)initWithFilePath:(NSString*)path;
// Evaluates whether the expectation's tag string matches active tags.
- (BOOL)doTagsMatch:(NSString*)tagsStr activeTags:(NSSet<NSString*>*)activeTags;
@end

namespace {
TestExpectations* g_shared_instance = nil;

enum class TagCategory {
  kOS,
  kDevice,
  kOther,
};

TagCategory GetTagCategory(NSString* tag) {
  if ([tag hasPrefix:@"ipad"] || [tag hasPrefix:@"iphone"]) {
    return TagCategory::kDevice;
  }
  if ([tag hasPrefix:@"ios"] || [tag hasPrefix:@"build-"]) {
    return TagCategory::kOS;
  }
  return TagCategory::kOther;
}
}  // namespace

@implementation TestExpectations {
  NSMutableDictionary<NSString*, TestExpectationEntry*>* _expectations;

  // Override for active tags (used in tests).
  NSSet<NSString*>* _activeTagsOverride;

  // Store original content for reparsing if tags are overridden.
  NSString* _content;

  // Path to the expectations file.
  NSString* _filePath;
}

- (instancetype)initWithFilePath:(NSString*)path {
  NSError* error = nil;
  NSString* content = [NSString stringWithContentsOfFile:path
                                                encoding:NSUTF8StringEncoding
                                                   error:&error];
  CHECK(content && !error) << "Error reading expectations file at " << path
                           << ": " << error;
  return [self initWithContent:content filePath:path];
}

+ (instancetype)sharedInstance {
  if (!g_shared_instance) {
    NSBundle* bundle = [NSBundle bundleForClass:[TestExpectations class]];
    NSString* path = [bundle pathForResource:@"test_expectations"
                                      ofType:@"txt"];
    CHECK(path) << "Failed to find test_expectations.txt in bundle " << bundle;
    g_shared_instance = [[TestExpectations alloc] initWithFilePath:path];
  }
  return g_shared_instance;
}

- (instancetype)initWithContent:(NSString*)content {
  return [self initWithContent:content filePath:nil];
}

- (instancetype)initWithContent:(NSString*)content
                       filePath:(NSString*)filePath {
  self = [super init];
  if (self) {
    _content = [content copy];
    _filePath = [filePath copy];
    _expectations = [NSMutableDictionary dictionary];
    [self parseExpectations:_content];
  }
  return self;
}

- (NSSet<NSString*>*)activeTags {
  if (_activeTagsOverride) {
    return _activeTagsOverride;
  }

  NSMutableSet<NSString*>* tags = [NSMutableSet set];
  [tags addObject:@"ios"];

  NSString* systemVersion = [UIDevice currentDevice].systemVersion;
  NSArray<NSString*>* components =
      [systemVersion componentsSeparatedByString:@"."];
  if (components.count > 0) {
    NSString* major = components[0];
    [tags addObject:[NSString stringWithFormat:@"ios%@", major]];
    if (components.count > 1) {
      NSString* minor = components[1];
      [tags addObject:[NSString stringWithFormat:@"ios%@.%@", major, minor]];
      if (components.count > 2) {
        NSString* patch = components[2];
        [tags addObject:[NSString stringWithFormat:@"ios%@.%@.%@", major, minor,
                                                   patch]];
      }
    }
  }

  // Add build number
  std::string buildNumber = base::SysInfo::GetIOSBuildNumber();
  if (!buildNumber.empty()) {
    NSString* buildNSString = base::SysUTF8ToNSString(buildNumber);
    NSString* prefixedBuild =
        [NSString stringWithFormat:@"build-%@", buildNSString];
    [tags addObject:[prefixedBuild lowercaseString]];
  }

#if TARGET_OS_SIMULATOR
  [tags addObject:@"simulator"];
#else
  [tags addObject:@"device"];
#endif

#if defined(ADDRESS_SANITIZER)
  [tags addObject:@"asan"];
#endif

#if BUILDFLAG(IS_IOS_MACCATALYST)
  [tags addObject:@"catalyst"];
#endif

#if defined(NDEBUG)
  [tags addObject:@"release"];
#else
  [tags addObject:@"debug"];
#endif

  std::string hardwareModel = base::SysInfo::HardwareModelName();
  NSString* modelNSString = base::SysUTF8ToNSString(hardwareModel);
  NSSet<NSString*>* deviceTags =
      [TestExpectations deviceTagsForHardwareModel:modelNSString
                                        formFactor:ui::GetDeviceFormFactor()];
  [tags unionSet:deviceTags];

  return [tags copy];
}

- (void)parseExpectations:(NSString*)content {
  NSSet<NSString*>* activeTags = [self activeTags];

  NSError* error = nil;
  NSRegularExpression* regex =
      [NSRegularExpression regularExpressionWithPattern:
                               @"^(?:([^\\s\\[]\\S*)\\s+)?(?:\\[([^\\]]+)\\]"
                               @"\\s+)?(\\S+)\\s+\\[([^\\]]+)\\](?:\\s*#.*)?$"
                                                options:0
                                                  error:&error];
  CHECK(!error) << "Failed to compile regex: " << error;

  NSArray<NSString*>* lines = [content componentsSeparatedByString:@"\n"];
  NSUInteger lineNumber = 0;
  for (NSString* line in lines) {
    lineNumber++;
    NSString* trimmed =
        [line stringByTrimmingCharactersInSet:[NSCharacterSet
                                                  whitespaceCharacterSet]];
    if (trimmed.length == 0 || [trimmed hasPrefix:@"#"]) {
      continue;
    }

    NSTextCheckingResult* match =
        [regex firstMatchInString:trimmed
                          options:0
                            range:NSMakeRange(0, trimmed.length)];
    if (!match) {
      continue;
    }

    NSString* bug = nil;
    if ([match rangeAtIndex:1].location != NSNotFound) {
      bug = [trimmed substringWithRange:[match rangeAtIndex:1]];
    }

    NSString* tagsStr = nil;
    if ([match rangeAtIndex:2].location != NSNotFound) {
      tagsStr = [trimmed substringWithRange:[match rangeAtIndex:2]];
    }

    NSString* testId = nil;
    if ([match rangeAtIndex:3].location != NSNotFound) {
      testId = [trimmed substringWithRange:[match rangeAtIndex:3]];
    }

    NSString* expectationsStr = nil;
    if ([match rangeAtIndex:4].location != NSNotFound) {
      expectationsStr = [trimmed substringWithRange:[match rangeAtIndex:4]];
    }

    // Validate tags
    if (![self doTagsMatch:tagsStr activeTags:activeTags]) {
      continue;
    }

    // Validate expectations
    TestExpectationType type = TestExpectationTypeNone;
    if (expectationsStr) {
      NSArray<NSString*>* expectations =
          [expectationsStr componentsSeparatedByString:@" "];
      for (NSString* exp in expectations) {
        NSString* trimmedExp =
            [exp stringByTrimmingCharactersInSet:[NSCharacterSet
                                                     whitespaceCharacterSet]];
        NSString* lowercaseExp = [trimmedExp lowercaseString];
        if ([lowercaseExp isEqualToString:@"failure"]) {
          type |= TestExpectationTypeFailure;
        } else if ([lowercaseExp isEqualToString:@"pass"]) {
          type |= TestExpectationTypePass;
        } else if ([lowercaseExp isEqualToString:@"skip"]) {
          type |= TestExpectationTypeSkip;
        } else if ([lowercaseExp isEqualToString:@"crash"]) {
          type |= TestExpectationTypeCrash;
        }
      }
    }

    if (type == TestExpectationTypeNone) {
      type = TestExpectationTypePass;
    }

    // Normalize test ID and store
    NSString* normalizedTestId = [self normalizeTestIdentifier:testId];
    TestExpectationEntry* entry = [[TestExpectationEntry alloc] init];
    entry.bug = bug ? bug : @"Expected failure";
    entry.type = type;
    entry.lineNumber = lineNumber;
    entry.filePath = _filePath;
    _expectations[normalizedTestId] = entry;
  }
}

- (TestExpectationEntry*)expectationEntryForTestCase:(NSString*)testClassName
                                          methodName:(NSString*)methodName {
  NSString* methodId =
      [NSString stringWithFormat:@"%@/%@", testClassName, methodName];
  NSString* normalizedMethodId = [self normalizeTestIdentifier:methodId];

  TestExpectationEntry* entry = _expectations[normalizedMethodId];
  if (entry) {
    return entry;
  }

  // Try class-level expectation
  NSString* normalizedClassId = [self normalizeTestIdentifier:testClassName];
  return _expectations[normalizedClassId];
}

- (NSString*)normalizeTestIdentifier:(NSString*)identifier {
  return [identifier stringByReplacingOccurrencesOfString:@"." withString:@"/"];
}

// Evaluates whether the expectation's tag string matches active tags.
//
// Note on tag matching semantics:
// - OS and Device categories use "ANY" (OR) semantics: if an expectation
// specifies
//   multiple tags within the OS or Device category (e.g., [ ios-17 ios-18 ] or
//   [ iphone ipad ]), matching ANY tag within that category satisfies the
//   requirement.
// - Other tags use "ALL" (AND) semantics: EVERY tag in the kOther category
// present in the
//   expectation (e.g., [ debug asan ]) must be present in the active tags for a
//   match.
//
// This divergence between ANY and ALL semantics is intentional to allow
// expectations that span multiple OS versions or device families while
// requiring specific build configurations.
- (BOOL)doTagsMatch:(NSString*)tagsStr
         activeTags:(NSSet<NSString*>*)activeTags {
  if (!tagsStr) {
    return YES;
  }

  NSArray<NSString*>* tags = [tagsStr componentsSeparatedByString:@" "];
  bool expectation_has_os_tag = false;
  bool os_matched = false;
  bool expectation_has_device_tag = false;
  bool device_matched = false;

  for (NSString* tag in tags) {
    NSString* trimmedTag =
        [tag stringByTrimmingCharactersInSet:[NSCharacterSet
                                                 whitespaceCharacterSet]];
    if (trimmedTag.length > 0) {
      NSString* lowercaseTag = [trimmedTag lowercaseString];
      bool is_match = [activeTags containsObject:lowercaseTag];
      TagCategory category = GetTagCategory(lowercaseTag);
      switch (category) {
        case TagCategory::kOS:
          expectation_has_os_tag = true;
          if (is_match) {
            os_matched = true;
          }
          break;
        case TagCategory::kDevice:
          expectation_has_device_tag = true;
          if (is_match) {
            device_matched = true;
          }
          break;
        case TagCategory::kOther:
          if (!is_match) {
            return NO;
          }
          break;
      }
    }
  }

  // For the expectation to match, all kOther tags must have matched in the loop
  // above (ALL semantics), and if any OS or Device tags were specified, at
  // least one tag from each specified category must match (ANY semantics).
  return (os_matched || !expectation_has_os_tag) &&
         (device_matched || !expectation_has_device_tag);
}

@end

@implementation TestExpectations (Testing)

- (void)setOverrideActiveTagsForTesting:(NSSet<NSString*>*)tags {
  // Store tags normalized to lowercase for testing consistency.
  NSMutableSet<NSString*>* normalizedTags = [NSMutableSet set];
  for (NSString* tag in tags) {
    [normalizedTags addObject:[tag lowercaseString]];
  }
  _activeTagsOverride = [normalizedTags copy];

  // Reparse expectations with the new tags.
  [_expectations removeAllObjects];
  if (_content) {
    [self parseExpectations:_content];
  }
}

+ (instancetype)sharedInstanceForTesting:(NSString*)content {
  CHECK(g_shared_instance == nil);
  g_shared_instance = [[TestExpectations alloc] initWithContent:content];
  return g_shared_instance;
}

+ (void)resetForTesting {
  g_shared_instance = nil;
}

// Generates device tags given a hardware model string and UI device form
// factor.
+ (NSSet<NSString*>*)deviceTagsForHardwareModel:(NSString*)hardwareModel
                                     formFactor:
                                         (ui::DeviceFormFactor)formFactor {
  NSMutableSet<NSString*>* tags = [NSMutableSet set];
  NSString* lowercaseModel = nil;

  if (hardwareModel.length > 0) {
    NSString* modelNSString = hardwareModel;
    // On iOS simulators, base::SysInfo::HardwareModelName() returns a
    // formatted string like "iOS Simulator (iPhone16,1)". Extract the actual
    // model identifier inside the parentheses so that tag matching and
    // classification work consistently across both simulator builds and
    // physical hardware.
    NSRange openParen = [modelNSString rangeOfString:@"("];
    NSRange closeParen = [modelNSString rangeOfString:@")"
                                              options:NSBackwardsSearch];
    if (openParen.location != NSNotFound && closeParen.location != NSNotFound &&
        closeParen.location > openParen.location + 1) {
      NSRange modelRange = NSMakeRange(
          openParen.location + 1, closeParen.location - openParen.location - 1);
      modelNSString = [modelNSString substringWithRange:modelRange];
    }

    lowercaseModel = [modelNSString lowercaseString];
    if (lowercaseModel.length > 0) {
      // Add both the exact hardware model identifier and its general model
      // prefix (stripped of the revision/generation suffix after the comma).
      // For example, if the extracted model name is "iPhone16,1", this
      // generates and adds both "iphone16,1" and "iphone16" as active device
      // tags.
      [tags addObject:lowercaseModel];
      NSRange commaRange = [lowercaseModel rangeOfString:@","];
      if (commaRange.location != NSNotFound) {
        [tags addObject:[lowercaseModel substringToIndex:commaRange.location]];
      }
    }
  }

  // Derive the general device family tag directly from the model identifier.
  if ([lowercaseModel hasPrefix:@"ipad"]) {
    [tags addObject:@"ipad"];
  } else if ([lowercaseModel hasPrefix:@"iphone"]) {
    [tags addObject:@"iphone"];
  } else {
    // Fallback if model string was unavailable or unrecognized.
    if (formFactor == ui::DEVICE_FORM_FACTOR_TABLET) {
      [tags addObject:@"ipad"];
    } else if (formFactor == ui::DEVICE_FORM_FACTOR_PHONE) {
      [tags addObject:@"iphone"];
    }
  }

  return [tags copy];
}

@end
