//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//
//===----------------------------------------------------------------------===//

@_spi(SourceKitLSP) import LanguageServerProtocol
@_spi(SourceKitLSP) import SKLogging
import SourceKitLSP
import SwiftParser
import SwiftSyntax

// MARK: - Attribute parsing

/// Get the traits applied to a testing attribute.
///
/// - Parameters:
///   - testAttribute: The attribute to inspect.
///
/// - Returns: An array of `ExprSyntax` instances representing the traits
///   applied to `testAttribute`. If the attribute has no traits, the empty
///   array is returned.
private func traits(ofTestAttribute testAttribute: AttributeSyntax) -> [ExprSyntax] {
  guard let argument = testAttribute.arguments, case let .argumentList(argumentList) = argument else {
    return []
  }

  // Skip the display name if present.
  var traitArgumentsRange = argumentList.startIndex..<argumentList.endIndex
  if let firstArgument = argumentList.first,
    firstArgument.label == nil,
    firstArgument.expression.is(StringLiteralExprSyntax.self)
  {
    traitArgumentsRange = argumentList.index(after: argumentList.startIndex)..<argumentList.endIndex
  }

  // Look for any traits in the remaining arguments and slice them off.
  if let labelledArgumentIndex = argumentList[traitArgumentsRange].firstIndex(where: { $0.label != nil }) {
    // There is an argument with a label, so splice there.
    traitArgumentsRange = traitArgumentsRange.lowerBound..<labelledArgumentIndex
  }

  return argumentList[traitArgumentsRange].map(\.expression)
}

/// Contains information about a testing attribute such as `@Test` or `@Suite`.
struct TestingAttributeData {
  /// The display name in the attribute, if any.
  let displayName: String?

  /// The tags applied to the attribute.
  let tags: [String]

  /// Whether or not the attributed test is unconditionally disabled.
  ///
  /// Disabled tests can be presented differently in IDEs to indicate that they will never run, although they will still
  /// be represented in the results.
  let isDisabled: Bool

  /// Whether or not the attributed test is hidden.
  ///
  /// Hidden tests are not reported by SourceKit-LSP and are not run automatically.
  let isHidden: Bool

  /// Extract the testing attribute data from the given attribute, which is assumed to be an `@Test` or `@Suite`
  /// attribute.
  init(attribute: AttributeSyntax) {
    // If the first argument is an unlabelled string literal, it is the
    // display name of the test. Otherwise, the test does not have a display
    // name.
    if case .argumentList(let argumentList) = attribute.arguments,
      let firstArgument = argumentList.first,
      firstArgument.label == nil,
      let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self)
    {
      self.displayName = stringLiteral.representedLiteralValue
    } else {
      self.displayName = nil
    }

    let traitArguments = traits(ofTestAttribute: attribute)

    // Map the arguments to tag's names.
    self.tags = traitArguments.lazy
      .compactMap { $0.as(FunctionCallExprSyntax.self) }
      .filter { functionCall in
        switch functionCall.calledExpression.as(MemberAccessExprSyntax.self)?.fullyQualifiedName {
        case "tags", "Tag.List.tags", "Testing.Tag.List.tags":
          return true
        default:
          return false
        }
      }.flatMap(\.arguments)
      .compactMap {
        if let memberAccess = $0.expression.as(MemberAccessExprSyntax.self) {
          var components = memberAccess.components[...]
          if components.starts(with: ["Testing", "Tag"]) {
            components = components.dropFirst(2)
          } else if components.starts(with: ["Tag"]) {
            components = components.dropFirst(1)
          }
          return components.joined(separator: ".")
        }
        return nil
      }

    self.isDisabled = traitArguments.lazy
      .compactMap { $0.as(FunctionCallExprSyntax.self) }
      .filter { functionCall in
        switch functionCall.calledExpression.as(MemberAccessExprSyntax.self)?.fullyQualifiedName {
        case "disabled", "ConditionTrait.disabled", "Testing.ConditionTrait.disabled":
          return true
        default:
          return false
        }
      }
      .contains { functionCall in
        // Ignore disabled traits which have an `if:` parameter since
        // they're conditional.
        let hasConditionParam = functionCall.arguments.lazy
          .compactMap(\.label?.text)
          .contains("if")
        if hasConditionParam {
          return false
        }

        // Ignore disabled traits which have a trailing closure since
        // they're conditional.
        if functionCall.trailingClosure != nil {
          return false
        }

        return true
      }

    self.isHidden = traitArguments.lazy
      .compactMap { $0.as(MemberAccessExprSyntax.self) }
      .contains { memberAccess in
        switch memberAccess.fullyQualifiedName {
        case "hidden", "HiddenTrait.hidden", "Testing.HiddenTrait.hidden":
          true
        default:
          false
        }
      }
  }
}

// MARK: - Test scanning

final class SyntacticSwiftTestingTestScanner: SyntaxVisitor {
  /// The `DocumentSnapshot` of the syntax tree that is being visited.
  ///
  /// Used to convert `AbsolutePosition` to line-column.
  private let snapshot: DocumentSnapshot

  /// Whether all tests discovered by the scanner should be marked as disabled.
  ///
  /// This is the case when the scanner is looking for tests inside a disabled suite.
  private let allTestsDisabled: Bool

  /// The names of the types that this scanner is scanning members for.
  ///
  /// For example, when scanning for tests inside `Bar` in the following, this is `["Foo", "Bar"]`
  ///
  /// ```swift
  /// struct Foo {
  ///   struct Bar {
  ///     @Test func myTest() {}
  ///   }
  /// }
  /// ```
  private let parentTypeNames: [String]

  /// The discovered test items.
  private var result: [AnnotatedTestItem] = []

  private init(
    snapshot: DocumentSnapshot,
    allTestsDisabled: Bool,
    parentTypeNames: [String]
  ) {
    self.snapshot = snapshot
    self.allTestsDisabled = allTestsDisabled
    self.parentTypeNames = parentTypeNames
    super.init(viewMode: .fixedUp)
  }

  /// Public entry point. Scans the syntax tree of the given snapshot for swift-testing tests.
  package static func findTestSymbols(
    in snapshot: DocumentSnapshot,
    syntaxTreeManager: SyntaxTreeManager
  ) async -> [AnnotatedTestItem] {
    guard snapshot.text.contains("Suite") || snapshot.text.contains("Test") else {
      // If the file contains swift-testing tests, it must contain a `@Suite` or `@Test` attribute.
      // Only check for the attribute name because the attribute may be module qualified and contain an arbitrary amount
      // of whitespace.
      // This is intended to filter out files that obviously do not contain tests.
      return []
    }
    let syntaxTree = await syntaxTreeManager.syntaxTree(for: snapshot)
    let visitor = SyntacticSwiftTestingTestScanner(
      snapshot: snapshot,
      allTestsDisabled: false,
      parentTypeNames: []
    )
    visitor.walk(syntaxTree)
    return visitor.result
  }

  /// Visit a class/struct/... or extension declaration.
  ///
  /// `typeNames` is the name of the class struct or, if this is an extension, an array containing the components of the
  /// extended type. For example, `extension Foo.Bar {}` passes `["Foo", "Bar"]` as `typeNames`.
  /// `typeNames` must not be empty.
  private func visitTypeOrExtensionDecl(
    _ node: any DeclGroupSyntax,
    typeNames: [String]
  ) -> SyntaxVisitorContinueKind {
    precondition(!typeNames.isEmpty)
    let superclassName = node.inheritanceClause?.inheritedTypes.first?.type.as(IdentifierTypeSyntax.self)?.name.text
    if superclassName == "XCTestCase" {
      return .skipChildren
    }

    let suiteAttribute = node.attributes
      .compactMap { $0.as(AttributeSyntax.self) }
      .first { $0.isNamed("Suite", inModuleNamed: "Testing") }
    let attributeData: TestingAttributeData? =
      if let suiteAttribute {
        TestingAttributeData(attribute: suiteAttribute)
      } else {
        nil
      }

    if attributeData?.isHidden ?? false {
      return .skipChildren
    }

    let displayName = attributeData?.displayName ?? typeNames.last!
    let typeNames = typeNames.map { backtickIfNeeded($0).name }

    let memberScanner = SyntacticSwiftTestingTestScanner(
      snapshot: snapshot,
      allTestsDisabled: attributeData?.isDisabled ?? false,
      parentTypeNames: parentTypeNames + typeNames
    )
    memberScanner.walk(node.memberBlock)

    guard !memberScanner.result.isEmpty || suiteAttribute != nil else {
      // Only include this declaration if it has an `@Suite` attribute or contains nested tests.
      return .skipChildren
    }

    let range = snapshot.positionRange(
      of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
    )
    // Members won't be extensions since extensions will only be at the top level.
    let testItem = AnnotatedTestItem(
      testItem: TestItem(
        id: (parentTypeNames + typeNames).joined(separator: "/"),
        label: displayName,
        disabled: (attributeData?.isDisabled ?? false) || allTestsDisabled,
        style: TestStyle.swiftTesting,
        location: Location(uri: snapshot.uri, range: range),
        children: memberScanner.result.map(\.testItem),
        tags: attributeData?.tags.map(TestTag.init(id:)) ?? []
      ),
      isExtension: node.is(ExtensionDeclSyntax.self)
    )
    result.append(testItem)
    return .skipChildren
  }

  override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
    guard let identifier = node.name.identifier else {
      return .skipChildren
    }
    return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
  }

  override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
    guard let identifier = node.name.identifier else {
      return .skipChildren
    }
    return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
  }

  override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
    guard let identifier = node.name.identifier else {
      return .skipChildren
    }
    return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
  }

  override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
    guard let newContextComponents = node.extendedType.components else {
      return .skipChildren
    }

    return visitTypeOrExtensionDecl(node, typeNames: newContextComponents)
  }

  override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
    guard let identifier = node.name.identifier else {
      return .skipChildren
    }
    return visitTypeOrExtensionDecl(node, typeNames: [identifier.name])
  }

  /// If the given name requires backticks to be a valid decl identifier,
  /// applies backticks and returns `true` along with the new name. Otherwise
  /// returns `false` with the name.
  func backtickIfNeeded(_ name: String) -> (backticked: Bool, name: String) {
    var name = name
    if name.first == "`" && name.last == "`" {
      name = String(name.dropFirst().dropLast())
    }
    let needsBackticks = !name.isValidSwiftIdentifier(for: .variableName)
    return (needsBackticks, needsBackticks ? "`\(name)`" : name)
  }

  override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind {
    let testAttribute = node.attributes
      .compactMap { $0.as(AttributeSyntax.self) }
      .first { $0.isNamed("Test", inModuleNamed: "Testing") }

    guard let testAttribute, let identifier = node.name.identifier else {
      return .skipChildren
    }
    let attributeData = TestingAttributeData(attribute: testAttribute)
    if attributeData.isHidden {
      return .skipChildren
    }

    let parameters = node.signature.parameterClause.parameters.map { param in
      let result =
        if let identifier = param.firstName.identifier {
          backtickIfNeeded(identifier.name).name
        } else {
          // Something like `_`, leave as-is.
          param.firstName.text
        }
      return "\(result):"
    }.joined()

    let (hasBackticks, baseName) = backtickIfNeeded(identifier.name)
    let fullName = "\(baseName)(\(parameters))"

    // If we have a display name provided by the attribute, use it, otherwise
    // we can infer the display name from a raw identifier if we have one.
    //
    // A raw identifier is considered an alternative way of spelling the display
    // name, so e.g these have the same display name:
    //
    // ```
    // @Test("foo bar") func foo() {}
    // @Test func `foo bar`() {}
    // ```
    //
    // as such it shouldn't include any parameters. If we just have a regular
    // name then we use the full name as the display name.
    let displayName = attributeData.displayName ?? (hasBackticks ? identifier.name : fullName)

    let range = snapshot.positionRange(
      of: node.positionAfterSkippingLeadingTrivia..<node.endPositionBeforeTrailingTrivia
    )
    let testItem = AnnotatedTestItem(
      testItem: TestItem(
        id: (parentTypeNames + [fullName]).joined(separator: "/"),
        label: displayName,
        disabled: attributeData.isDisabled || allTestsDisabled,
        style: TestStyle.swiftTesting,
        location: Location(uri: snapshot.uri, range: range),
        children: [],
        tags: attributeData.tags.map(TestTag.init(id:))
      ),
      isExtension: false
    )
    result.append(testItem)
    return .visitChildren
  }
}

// MARK: - SwiftSyntax Utilities

fileprivate extension AttributeSyntax {
  /// Check whether or not this attribute is named with the specified name and
  /// module.
  ///
  /// The attribute's name is accepted either without or with the specified
  /// module name as a prefix to allow for either syntax. The name of this
  /// attribute must not include generic type parameters.
  ///
  /// - Parameters:
  ///   - name: The `"."`-separated type name to compare against.
  ///   - moduleName: The module the specified type is declared in.
  ///
  /// - Returns: Whether or not this type has the given name.
  func isNamed(_ name: String, inModuleNamed moduleName: String) -> Bool {
    if let identifierType = attributeName.as(IdentifierTypeSyntax.self) {
      return identifierType.name.text == name
    } else if let memberType = attributeName.as(MemberTypeSyntax.self),
      let baseIdentifierType = memberType.baseType.as(IdentifierTypeSyntax.self),
      baseIdentifierType.genericArgumentClause == nil
    {
      return memberType.name.text == name && baseIdentifierType.name.text == moduleName
    }

    return false
  }
}

fileprivate extension MemberAccessExprSyntax {
  /// The fully-qualified name of this instance (subject to available
  /// information.)
  ///
  /// The value of this property are all the components of the based name
  /// name joined together with `.`.
  var fullyQualifiedName: String {
    components.joined(separator: ".")
  }

  /// The name components of this instance (subject to available
  /// information.)
  ///
  /// The value of this property is this base name of this instance,
  /// i.e. the string value of `base` preceeded with any preceding base names
  /// and followed by its `name` property.
  ///
  /// For example, if this instance represents
  /// the expression `x.y.z(123)`, the value of this property is
  /// `["x", "y", "z"]`.
  var components: [String] {
    if let declReferenceExpr = base?.as(DeclReferenceExprSyntax.self) {
      return [declReferenceExpr.baseName.text, declName.baseName.text]
    } else if let baseMemberAccessExpr = base?.as(MemberAccessExprSyntax.self) {
      return baseMemberAccessExpr.components + [declName.baseName.text]
    }
    return [declName.baseName.text]
  }
}

fileprivate extension TypeSyntax {
  /// If this type is a simple chain of `MemberTypeSyntax` and `IdentifierTypeSyntax`, return the components that make
  /// up the qualified type.
  ///
  /// ### Examples
  ///  - `Foo.Bar` returns `["Foo", "Bar"]`
  ///  - `Foo` returns `["Foo"]`
  ///  - `[Int]` returns `nil`
  var components: [String]? {
    switch self.as(TypeSyntaxEnum.self) {
    case .identifierType(let identifierType):
      return [identifierType.name.identifier?.name ?? identifierType.name.text]
    case .memberType(let memberType):
      guard let baseComponents = memberType.baseType.components else {
        return nil
      }
      return baseComponents + [memberType.name.identifier?.name ?? memberType.name.text]
    default:
      return nil
    }
  }
}
