/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

internal fun throwCountMismatchError(
    failedAssertionMessage: String,
    selector: TestNodeSelector,
    expectedCount: Int,
    matchedNodes: List<TestNode>
): Nothing {
  throw AssertionError(
      buildCountMismatchError(failedAssertionMessage, selector, expectedCount, matchedNodes))
}

internal fun buildCountMismatchError(
    failedAssertionMessage: String,
    selector: TestNodeSelector,
    expectedCount: Int,
    matchedNodes: List<TestNode>
): String = buildString {
  appendLine(failedAssertionMessage)
  appendLine("Reason: Expected exactly $expectedCount node(s), but found ${matchedNodes.size}")
  if (matchedNodes.isNotEmpty()) {
    appendLine("Node(s) found:")
    matchedNodes.printTo(this)
  }
  appendLine("Selector used: ${selector.description}")
}

internal fun throwNoMatchingNodeForSelectionError(
    failedAssertionMessage: String,
    selector: TestNodeSelector
): Nothing {
  throw AssertionError(buildNoMatchingNodeForSelectionError(failedAssertionMessage, selector))
}

internal fun buildNoMatchingNodeForSelectionError(
    failedAssertionMessage: String,
    selector: TestNodeSelector,
    nodes: List<TestNode> = emptyList()
): String = buildString {
  appendLine(failedAssertionMessage)
  appendLine("Reason: Could not find any matching node for selection")
  if (nodes.isNotEmpty()) {
    appendLine("Node(s) found:")
    nodes.printTo(this)
  }
  appendLine("Selector used: ${selector.description}")
}

internal fun throwGeneralError(
    failedAssertionMessage: String,
    selector: TestNodeSelector,
    node: TestNode
): Nothing {
  val message = buildString {
    appendLine(failedAssertionMessage)
    node.printTo(this)
    appendLine("Selector used: ${selector.description}")
  }
  throw AssertionError(message)
}

internal fun buildInvalidIndexError(
    selector: TestNodeSelector,
    index: Int,
    nodes: List<TestNode>
): String = buildString {
  appendLine("Failed selection: index is out of bounds")
  append("Reason: Requested a node at index $index, ")
  if (nodes.isEmpty()) appendLine("but there are no nodes available")
  else if (nodes.size == 1) appendLine("but only 1 node is available")
  else appendLine("but only ${nodes.size} nodes are available")
  if (nodes.isNotEmpty()) {
    appendLine("Node(s) found:")
    nodes.printTo(this)
  }
  appendLine("Selector used: ${selector.description}")
}

internal fun throwAssertAnyFailError(
    matcher: TestNodeMatcher,
    selector: TestNodeSelector,
    nodes: List<TestNode>
): Nothing {
  val message = buildString {
    appendLine("Failed: assertAny(${matcher.description})")
    if (nodes.isEmpty()) {
      appendLine("Reason: Could not find any matching node for selection")
    } else {
      appendLine("Reason: None of the selected nodes match the expected condition")
      appendLine("Node(s) found:")
      nodes.printTo(this)
    }
    appendLine("Selector used: ${selector.description}")
  }
  throw AssertionError(message)
}

internal fun throwAssertAllFailError(
    matcher: TestNodeMatcher,
    selector: TestNodeSelector,
    failedNodes: List<TestNode>
): Nothing {
  val message = buildString {
    appendLine("Failed: assertAll(${matcher.description})")
    appendLine("Reason: The following nodes do not match the expected condition:")
    failedNodes.printTo(this)
    appendLine("Selector used: ${selector.description}")
  }
  throw AssertionError(message)
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import com.facebook.litho.AttributeKey
import com.facebook.litho.testing.api.TestNodeAttributes.ContentDescription
import com.facebook.litho.testing.api.TestNodeAttributes.Enabled
import com.facebook.litho.testing.api.TestNodeAttributes.TestKey
import com.facebook.litho.widget.WidgetAttributes

/**
 * This filter will match all test nodes that represent a [Component] with the given class type
 * [componentClass].
 *
 * An example usage: `hasType(CounterComponent::class.java)`
 */
fun hasType(type: Class<*>): TestNodeMatcher =
    TestNodeMatcher("is a component of type ${type.canonicalName}") { testNode ->
      testNode.componentType == type
    }

inline fun <reified T> hasType(): TestNodeMatcher = hasType(T::class.java)

fun isEnabled(): TestNodeMatcher =
    TestNodeMatcher("is enabled") { node -> node.getAttribute(Enabled) == true }

fun isNotEnabled(): TestNodeMatcher =
    TestNodeMatcher("is not enabled") { node -> node.getAttribute(Enabled) != true }

fun hasTestKey(key: String): TestNodeMatcher =
    TestNodeMatcher("has test key \"$key\"") { node -> node.getAttribute(TestKey) == key }

/**
 * Returns a [TestNodeMatcher] that verifies if the given node text matches the exact [text]
 *
 * It uses the attribute [WidgetAttributes.Text] to look for the text value. One example of Litho
 * widget that relies on this attribute is the [TextSpec]. This means that you can use this matcher
 * to verify the text used with any instance of Litho's Text.
 */
fun hasText(text: CharSequence): TestNodeMatcher {
  return TestNodeMatcher("has text \"$text\"") { node ->
    val textValues = node.getAttribute(WidgetAttributes.Text)
    textValues?.any { it == text } ?: false
  }
}

/** Returns a [TestNodeMatcher] that verifies if the given node contains the given [text] */
fun hasTextContaining(text: CharSequence): TestNodeMatcher {
  return TestNodeMatcher("has text containing \"$text\"") { node ->
    val textValues = node.getAttribute(WidgetAttributes.Text)
    textValues?.any { it.contains(text) } ?: false
  }
}

/**
 * Returns a [TestNodeMatcher] that verifies if the given node has a content description which is an
 * exact match with [description].
 */
fun hasContentDescription(description: CharSequence): TestNodeMatcher {
  return TestNodeMatcher("has contentDescription \"$description\"") { node ->
    node.getAttribute(ContentDescription) == description
  }
}

/**
 * Generic purpose test node filters based on the attributes system. This filter will match all
 * [TestNode] whose [Component] has set [value] for the attribute with key [key]
 */
fun <T> hasAttribute(key: AttributeKey<T>, value: T): TestNodeMatcher {
  return TestNodeMatcher("${key.description} = $value") { testNode: TestNode ->
    testNode.getAttribute(key) == value
  }
}

/**
 * Returns a [TestNodeMatcher] that verifies if a [TestNode] is the root of a component tree.
 *
 * A root node is the node that has no associated parent.
 */
fun isRoot(): TestNodeMatcher {
  return TestNodeMatcher("is root") { node -> node.parent == null }
}

/**
 * Returns a [TestNodeMatcher] that verifies if a [TestNode] has a parent [TestNode] that matches
 * the given [matcher].
 *
 * If the [TestNode] has no parent (it is the root) then the returned [TestNodeMatcher] will never
 * be matched.
 */
fun hasParent(matcher: TestNodeMatcher): TestNodeMatcher {
  return TestNodeMatcher("has parent that ${matcher.description}") { testNode: TestNode ->
    val parent = testNode.parent
    if (parent == null) {
      false
    } else {
      matcher.matches(parent)
    }
  }
}

/**
 * Returns a [TestNodeMatcher] that will match [TestNode]s which have an ancestor that match with
 * the given [matcher].
 *
 * We consider an ancestor to be any [TestNode] in the direct line of parents. This is the direct
 * parent, the parent of the parent, and so on...
 */
fun hasAncestor(matcher: TestNodeMatcher): TestNodeMatcher {
  return TestNodeMatcher("has ancestor that ${matcher.description}") { testNode: TestNode ->
    testNode.ancestors.any { ancestor -> matcher.matches(ancestor) }
  }
}

/**
 * Returns a [TestNodeMatcher] that will match [TestNode]s which have one child that matches with
 * the given [matcher].
 */
fun hasChild(matcher: TestNodeMatcher): TestNodeMatcher {
  return TestNodeMatcher("has child that ${matcher.description}") { testNode: TestNode ->
    testNode.children.any { child -> matcher.matches(child) }
  }
}

/**
 * Returns a [TestNodeMatcher] that will match [TestNode]s which have one descendant that matches
 * the given [matcher].
 *
 * We consider a descendant of a [TestNode] to be any node that is part of its subtree.
 */
fun hasDescendant(matcher: TestNodeMatcher): TestNodeMatcher {
  fun checkHasDescendant(node: TestNode, matcher: TestNodeMatcher): Boolean {
    if (node.children.any(matcher::matches)) return true

    return node.children.any { child -> checkHasDescendant(child, matcher) }
  }

  return TestNodeMatcher("has descendant that ${matcher.description}") { testNode: TestNode ->
    checkHasDescendant(testNode, matcher)
  }
}

private val TestNode.ancestors: Iterable<TestNode>
  get() =
      object : Iterable<TestNode> {
        override fun iterator(): Iterator<TestNode> =
            object : Iterator<TestNode> {

              private var next = parent

              override fun hasNext(): Boolean = next != null

              override fun next(): TestNode = next!!.also { next = it.parent }
            }
      }


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import com.facebook.litho.ClickEvent

/**
 * Dispatches a click event for the node represented in the given selection.
 *
 * It will throw an exception if this selection is invalid.
 */
fun TestNodeSelection.performClick() {
  val node = fetchTestNode("Failed: performClick")

  val clickHandler =
      node.clickHandler
          ?: throwGeneralError(
              "Failed performClick: the selected node has no click handler", selector, node)

  clickHandler.dispatchEvent(ClickEvent())
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import android.content.Context
import android.os.Looper
import android.view.View
import androidx.test.core.app.ApplicationProvider.getApplicationContext
import com.facebook.litho.Component
import com.facebook.litho.ComponentContext
import com.facebook.litho.ComponentTree
import com.facebook.litho.LithoView
import com.facebook.litho.config.ComponentsConfiguration
import com.facebook.litho.testing.BaseThreadLooperController
import com.facebook.litho.testing.ResolveAndLayoutThreadLooperController
import com.facebook.litho.testing.ThreadLooperController
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import org.robolectric.Shadows.shadowOf

class LithoRule : TestRule, TestNodeSelectionProvider {

  private var componentContext: ComponentContext? = null
  private lateinit var testContext: TestContext

  private var threadLooperController: BaseThreadLooperController = ThreadLooperController()

  override fun apply(statement: Statement, description: Description): Statement =
      object : Statement() {
        override fun evaluate() {
          ensureThreadLooperType()

          try {
            componentContext = ComponentContext(getApplicationContext<Context>())
            threadLooperController.init()
            statement.evaluate()
          } finally {
            threadLooperController.clean()
          }
        }
      }

  fun render(componentProvider: () -> Component): LithoRule = also {
    setupLithoViewWithComponent(
        componentContext = checkNotNull(componentContext), componentProvider = componentProvider)
  }

  override fun selectNode(matcher: TestNodeMatcher): TestNodeSelection {
    idle()
    return TestNodeSelection(testContext = testContext, selector = testNodeSelector(matcher))
  }

  override fun selectNodes(matcher: TestNodeMatcher): TestNodeCollectionSelection {
    idle()
    return TestNodeCollectionSelection(
        testContext = testContext, selector = testNodeSelector(matcher))
  }

  private fun setupLithoViewWithComponent(
      componentContext: ComponentContext,
      componentProvider: () -> Component?
  ) {
    val lithoView = LithoView(componentContext)
    val componentTree = ComponentTree.create(componentContext).build()
    val component = componentProvider()

    testContext = LithoTestContext(lithoView)
    componentTree.setRoot(component)

    lithoView.componentTree = componentTree

    lithoView.onAttachedToWindowForTest()
    lithoView.measure(DEFAULT_WIDTH_SPEC, DEFAULT_HEIGHT_SPEC)
    lithoView.layout(0, 0, lithoView.measuredWidth, lithoView.measuredHeight)
  }

  /**
   * Runs through all tasks on the background thread and main lopper, blocking until it completes.
   * Use if there are any async events triggered by layout ( ie visibility events) to manually drain
   * the queue
   */
  private fun idle() {
    threadLooperController.runToEndOfTasksSync()
    shadowOf(Looper.getMainLooper()).idle()
  }

  private fun ensureThreadLooperType() {
    if (ComponentsConfiguration.isSplitResolveAndLayoutWithSplitHandlers() &&
        threadLooperController is ThreadLooperController) {
      threadLooperController = ResolveAndLayoutThreadLooperController()
    } else if (!ComponentsConfiguration.isSplitResolveAndLayoutWithSplitHandlers() &&
        threadLooperController is ResolveAndLayoutThreadLooperController) {
      threadLooperController = ThreadLooperController()
    }
  }

  companion object {
    private val DEFAULT_WIDTH_SPEC: Int =
        View.MeasureSpec.makeMeasureSpec(1080, View.MeasureSpec.EXACTLY)
    private val DEFAULT_HEIGHT_SPEC: Int =
        View.MeasureSpec.makeMeasureSpec(2040, View.MeasureSpec.EXACTLY)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import com.facebook.litho.LithoView

class LithoTestContext(private val lithoView: LithoView) : TestContext {

  private val testNodesListResolver = TestNodesListResolver()

  override fun provideAllTestNodes(): List<TestNode> {
    return testNodesListResolver.getCurrentTestNodes(lithoView)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import android.annotation.SuppressLint
import android.view.ViewGroup
import com.facebook.litho.Component
import com.facebook.litho.LithoLayoutResult
import com.facebook.litho.LithoView
import com.facebook.litho.NestedTreeHolderResult
import com.facebook.litho.getMountedContent
import java.util.LinkedList

/**
 * Allows traversing through all the [Component] that define a hierarchy in a given [LithoView].
 *
 * It is a generic purpose traversion, which allow whoever calls [traverse] to perform an operation
 * at any component that is traversed (and its parent).
 */
class LithoViewComponentsTraverser {

  fun traverse(
      lithoView: LithoView,
      onComponentFound: (component: Component, parent: Component?) -> Unit
  ) {
    val rootLayoutResult = lithoView.extractLayoutResult(isRoot = true)
    traverse(layoutResult = rootLayoutResult, onComponentFound = onComponentFound)
  }

  private fun traverse(
      layoutResult: LithoLayoutResult?,
      onComponentFound: (component: Component, parent: Component?) -> Unit
  ) {
    if (layoutResult == null) return

    val visited = HashSet<LithoLayoutResult>()

    val layoutsStack = LinkedList<TraverseNode>()
    layoutsStack.push(TraverseNode(layoutResult, null))

    while (layoutsStack.isNotEmpty()) {
      val (currentLayoutResult, parentComponent) = layoutsStack.pop()

      if (currentLayoutResult in visited) {
        continue
      }

      visited.add(currentLayoutResult)

      val lithoNode = currentLayoutResult.node
      val components = lithoNode.scopedComponentInfos.asReversed().mapNotNull { it.component }
      val lastScopedComponent = components.lastOrNull()

      if (currentLayoutResult is NestedTreeHolderResult) {
        val nestedResult = currentLayoutResult.nestedResult

        /*
         * Let's consider an example where we have this hierarchy:
         *
         * - ComponentThatUsesLithoLayoutSizeSpecs (uses @OnCreateLayoutWithSizeSpecs)
         *   - Text
         *
         * This will generate this hierarchy of LayoutResults
         * #1. NestedTreeHolderResult (components: [ComponentThatUsesLithoLayoutSizeSpecs])
         *  #2. NestedTreeHolderResult (components: [ComponentThatUsesLithoLayoutSizeSpecs, Text])
         *   #3. LithoLayoutResult (components: [Text])
         *
         * Therefore we only let the "traversing" of the components for the LayoutResult for #2,
         * since considering #1 and #3 would give repeated information.
         *
         * The other scenario would be where the layout spec that uses @OnCreateLayoutWithSizeSpecs uses
         * a Row/Column wrapping a MountSpec:
         * - ComponentThatUsesLithoLayoutSizeSpecs (uses @OnCreateLayoutWithSizeSpecs)
         *  - Row
         *   - Text
         *
         * This will generate this hierarchy of LayoutResults:
         *
         * #1  NestedTreeHolderResult (components: [ComponentThatUsesLithoLayoutSizeSpecs]
         *  #2 LithoLayoutResult (components: [ComponentThatUsesLithoLayoutSizeSpecs, Row])
         *    #3 NestedTreeHolderResult (components: [Text])
         *      #4 LithoLayoutResult (components: [Text])
         *
         * In this case we we will only process #2 (a LithoLayoutResult with children) and
         * #3 (a NestedTreeHolderResult whose nested result is a LithoLayoutResult with no children)
         * */
        if (nestedResult != null &&
            (nestedResult.node.childCount > 0 || nestedResult is NestedTreeHolderResult)) {
          layoutsStack.push(TraverseNode(nestedResult, parentComponent))
          continue
        }
      }

      (listOf(parentComponent) + components).zipWithNext {
          currentParent: Component?,
          child: Component? ->
        if (child == null) error("Child should never be null")
        onComponentFound(child, currentParent)
      }

      currentLayoutResult.childResults.forEach { child ->
        layoutsStack.push(TraverseNode(child, lastScopedComponent))
      }
    }
  }

  @SuppressLint("RestrictedApi")
  private fun LithoView.extractLayoutResult(isRoot: Boolean): LithoLayoutResult? {
    val committedLayoutState = componentTree?.committedLayoutState

    check(!isRoot || committedLayoutState != null) {
      "No ComponentTree/Committed Layout/Layout Root found. Please call render() first"
    }

    return committedLayoutState?.rootLayoutResult
  }

  private val LithoLayoutResult.childResults: List<LithoLayoutResult>
    get() {
      val mountedContent = getMountedContent()
      if (mountedContent is ViewGroup) {
        val childrenLithoViews = mountedContent.firstLevelInnerLithoViews
        val results = childrenLithoViews.mapNotNull { it.extractLayoutResult(isRoot = false) }
        if (results.isNotEmpty()) return results.asReversed()
      }

      return (childCount - 1 downTo 0).map { getChildAt(it) }
    }

  /**
   * This method will perform a search from the given [ViewGroup] in order to find all first-level
   * occurences of a [LithoView].
   *
   * This means, that once a [LithoView] is found, we stop the search down that sub-tree. All of the
   * found [LithoView] will be used to start a new traversal with [traverse] - which will enable the
   * full traverse (even on scenarios of nested [LithoView]
   */
  private val ViewGroup.firstLevelInnerLithoViews: List<LithoView>
    get() {
      val lithoViews = mutableListOf<LithoView>()

      for (i in 0..childCount) {
        val child = getChildAt(i)
        if (child is LithoView) {
          lithoViews.add(child)
        } else {
          (child as? ViewGroup)?.let { lithoViews.addAll(child.firstLevelInnerLithoViews) }
        }
      }

      return lithoViews
    }

  internal data class TraverseNode(
      val layoutResult: LithoLayoutResult,
      val parentComponent: Component?
  )
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho

internal fun LithoLayoutResult.getMountedContent(): Any? {
  val view = context.mountedView as? LithoView
  val mountDelegateTarget = view?.mountDelegateTarget
  if (mountDelegateTarget != null) {
    val count = mountDelegateTarget.mountItemCount
    for (i in 0 until count) {
      val mountItem = mountDelegateTarget.getMountItemAt(i)
      val component = mountItem?.let { LithoRenderUnit.getRenderUnit(it).component } ?: continue
      if (component === node.tailComponent) return mountItem.content
    }
  }
  return null
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

interface TestContext {

  fun provideAllTestNodes(): List<TestNode>
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import com.facebook.litho.ActionAttributeKey
import com.facebook.litho.AttributeKey
import com.facebook.litho.ClickEvent
import com.facebook.litho.Component
import com.facebook.litho.EventHandler

/**
 * A test node is used to hold all the information needed to represent a given component in the
 * testing tree. This information is be used by testing actions (filter, interaction, assertion) and
 * also to represent them in a visual way (e.g String representation).
 */
class TestNode(private val component: Component) {

  val componentType: Class<*> = component::class.java

  private val commonPropsAttributes: Map<AttributeKey<*>, *> =
      mapOf(
          TestNodeAttributes.TestKey to component.commonProps?.testKey,
          TestNodeAttributes.Enabled to (component.commonProps?.isEnabled ?: false),
          TestNodeAttributes.ContentDescription to component.commonProps?.contentDescription,
          TestNodeActionAttributes.OnClick to component.commonProps?.clickHandler)

  private val componentAttributes: Map<AttributeKey<*>, *> = component.debugAttributes

  val clickHandler: EventHandler<ClickEvent>?
    get() = component.commonProps?.clickHandler

  fun <T> getAttribute(key: AttributeKey<T>): T =
      commonPropsAttributes[key] as? T ?: component.getDebugAttribute(key)

  val attributes: Set<Pair<AttributeKey<*>, *>> =
      (commonPropsAttributes + componentAttributes).entries.map { it.toPair() }.toSet()

  var parent: TestNode? = null

  var children: List<TestNode> = mutableListOf()
}

internal object TestNodeAttributes {

  val TestKey: AttributeKey<String?> = AttributeKey<String?>("testKey")

  val ContentDescription: AttributeKey<CharSequence?> =
      AttributeKey<CharSequence?>("contentDescription")

  val Enabled: AttributeKey<Boolean?> = AttributeKey<Boolean?>("isEnabled")
}

internal object TestNodeActionAttributes {

  val OnClick: ActionAttributeKey<EventHandler<ClickEvent>> =
      ActionAttributeKey<EventHandler<ClickEvent>>("OnClick")
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

/**
 * This abstraction allows you to represent any given predicate on a [TestNode]. This is the base to
 * the mechanism that allows filtering test nodes to perform assertions or the matchers used in the
 * assertions.
 */
class TestNodeMatcher(val description: String, private val predicate: (TestNode) -> Boolean) {

  fun matches(node: TestNode): Boolean = predicate(node)

  infix fun or(nodeMatcher: TestNodeMatcher): TestNodeMatcher =
      TestNodeMatcher("$description or ${nodeMatcher.description}") {
        matches(it) || nodeMatcher.matches(it)
      }

  infix fun and(nodeMatcher: TestNodeMatcher): TestNodeMatcher =
      TestNodeMatcher("$description and ${nodeMatcher.description}") {
        matches(it) && nodeMatcher.matches(it)
      }

  operator fun not(): TestNodeMatcher = TestNodeMatcher("not $description") { !matches(it) }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

/**
 * Lazy representation of a single test node with necessary logic to materialize on-demand.
 *
 * Note: materalizing the [TestNodeSelection] should result in exactly **1** test node unless the
 * [assertDoesNotExist] method is used. In any case, an [IllegalStateException] will be thrown if
 * more than 1 test nodes are realized.
 */
class TestNodeSelection(
    private val testContext: TestContext,
    internal val selector: TestNodeSelector
) {

  fun fetchTestNode(errorOnFail: String = "Failed"): TestNode {
    val result = fetchMatchingNodes()

    if (result.nodes.size != 1)
        when {
          result.selectionError != null ->
              throw AssertionError("$errorOnFail\n${result.selectionError}")
          result.nodes.isEmpty() -> throwNoMatchingNodeForSelectionError(errorOnFail, selector)
          else -> throwCountMismatchError(errorOnFail, selector, 1, result.nodes)
        }

    return result.nodes.first()
  }

  fun assertExists(): TestNodeSelection {
    fetchTestNode("Failed: assertExists")

    return this
  }

  fun assertDoesNotExist(): TestNodeSelection {
    val result = fetchMatchingNodes().nodes

    if (result.isNotEmpty()) {
      throwCountMismatchError("Failed: assertDoesNotExist", selector, 0, result)
    }

    return this
  }

  fun assert(matcher: TestNodeMatcher): TestNodeSelection {
    val testNode = fetchTestNode("Failed assertion: ${matcher.description}")

    if (!matcher.matches(testNode)) {
      throwGeneralError("Failed assertion: ${matcher.description}", selector, testNode)
    }

    return this
  }

  /**
   * Returns a [TestNodeSelection] that further filters the current one, by selecting the child
   * [TestNode] at [index].
   *
   * This assumes that there is order in the `Component`s children. For example, the first one
   * defined inside a `Column` or `Row` will be the one at index 0.
   *
   * If the current [TestNodeSelection] has no children or a child at the given [index] then
   * assertions (other than assertDoesNotExist) on the result will throw an [AssertionError].
   */
  fun selectChildAt(index: Int): TestNodeSelection {
    return selectChildren().selectAtIndex(index)
  }

  /**
   * Returns a [TestNodeCollectionSelection] that further defines the current one, by defining a
   * collection of [TestNode] composed by its children.
   *
   * The usage of this API assumes that the previous selection materializes into a single
   * [TestNode], so that the children can be extracted from.
   *
   * An example usage:
   * ```
   * rule.selectNode(hasType<Column>())
   *  .selectChildren()
   *  .assertCount(2)
   * ```
   */
  fun selectChildren(): TestNodeCollectionSelection {
    return TestNodeCollectionSelection(
        testContext, selector.plusSingleToManySelector("children") { it.children })
  }

  private fun fetchMatchingNodes(): SelectionResult {
    val nodes = testContext.provideAllTestNodes()
    return selector.map(nodes)
  }
}

/**
 * Lazy representation of a collection of test nodes with necessary logic to materialize on-demand.
 *
 * Note: materializing the [TestNodeSelection] should result a collection of test node unless the
 * [assertDoesNotExist] method is used.
 */
class TestNodeCollectionSelection(
    private val testContext: TestContext,
    private val selector: TestNodeSelector
) {

  fun assertExists(): TestNodeCollectionSelection {
    val result = fetchMatchingNodes()

    if (result.isEmpty()) {
      throwNoMatchingNodeForSelectionError("Failed: assertExists", selector)
    }

    return this
  }

  fun assertDoesNotExist(): TestNodeCollectionSelection {
    val result = fetchMatchingNodes()

    if (result.isNotEmpty()) {
      throwCountMismatchError("Failed: assertDoesNotExist", selector, 0, result)
    }

    return this
  }

  /**
   * Asserts that this selection is defined by [count] elements.
   *
   * It will throw an [AssertionError] if the number of elements is not equal to [count]
   */
  fun assertCount(count: Int): TestNodeCollectionSelection {
    val result = fetchMatchingNodes()

    if (result.size != count) {
      throwCountMismatchError("Failed: assertCount", selector, count, result)
    }

    return this
  }

  /**
   * Asserts that all the nodes in the collection are matching the given [matcher].
   *
   * It will throw an [AssertionError] if there is at least one node that does not match.
   *
   * Note: An empty collection with pass this assertion because it technically doesn't have any node
   * that fails the condition.
   */
  fun assertAll(matcher: TestNodeMatcher): TestNodeCollectionSelection {
    val result = fetchMatchingNodes()
    val failedNodes = result.filterNot { matcher.matches(it) }
    if (failedNodes.isNotEmpty()) {
      throwAssertAllFailError(matcher, selector, failedNodes)
    }

    return this
  }

  /**
   * Asserts that there is at least one node in the selection that matches [matcher].
   *
   * It will throw an [AssertionError] if there are no matching nodes.
   *
   * Note: An empty collection will also result in [AssertionError] since it technically doesn't
   * have any matching node
   */
  fun assertAny(matcher: TestNodeMatcher): TestNodeCollectionSelection {
    val result = fetchMatchingNodes()
    if (!result.any(matcher::matches)) {
      throwAssertAnyFailError(matcher, selector, result)
    }

    return this
  }

  fun selectAtIndex(index: Int): TestNodeSelection {
    return TestNodeSelection(testContext, selector.plusIndexNodeSelector(index))
  }

  fun selectFirst(): TestNodeSelection {
    return TestNodeSelection(
        testContext, selector.plusManyToSingleSelector("first node") { it.firstOrNull() })
  }

  fun selectLast(): TestNodeSelection {
    return TestNodeSelection(
        testContext, selector.plusManyToSingleSelector("last node") { it.lastOrNull() })
  }

  /**
   * Attempts to select all subsequent nodes that match the given [matcher].
   *
   * @return a new [TestNodeCollectionSelection]
   */
  fun selectAll(matcher: TestNodeMatcher): TestNodeCollectionSelection {
    return TestNodeCollectionSelection(testContext, selector.plusManyToManySelector(matcher))
  }

  /**
   * Attempts to select the only node that matches the given [matcher].
   *
   * This method returns a [TestNodeSelection] so it expects to find exactly 1 match. Materializing
   * without this condition holding true will result in an [AssertionError] unless asserted via
   * [assertDoesNotExist]
   */
  fun select(matcher: TestNodeMatcher): TestNodeSelection {
    return TestNodeSelection(
        testContext,
        // Using many to many here even though we're returning a TestNodeSelection (and not a
        // collection) because we want the result to be exactly 1. In the case where this doesn't
        // happen, the error trigger is delegated to TestNodeSelection which already handles 0 and
        // more results correctly
        selector.plusManyToManySelector(matcher))
  }

  fun fetchMatchingNodes(): List<TestNode> {
    val nodes = testContext.provideAllTestNodes()
    return selector.map(nodes).nodes
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

/**
 * This is the entry point through which nodes are discovered for testing.
 *
 * It will typically be implemented by a test rule
 */
interface TestNodeSelectionProvider {

  /**
   * Finds a test node that matches the given condition.
   *
   * The result is a lazy representation of the matching node which can then be evaluated on-demand.
   * For usage patterns and concepts, see [TestNodeSelection]
   */
  fun selectNode(matcher: TestNodeMatcher): TestNodeSelection

  /**
   * Finds a collection of tests nodes that match the given conditions.
   *
   * The result is a lazy representation of the matching nodes which can then be evaluated
   * on-demand. For usage patterns and concepts, see [TestNodeCollectionSelection]
   */
  fun selectNodes(matcher: TestNodeMatcher): TestNodeCollectionSelection
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

/** Transforms a given set of nodes into a new list of [TestNode]. */
class TestNodeSelector(
    val description: String,
    private val parentSelector: TestNodeSelector? = null,
    private val throwOnSelectionError: Boolean = false,
    private val selector: TestNodeSelector.(List<TestNode>) -> SelectionResult
) {

  fun map(nodes: List<TestNode>): SelectionResult {
    val parentResult = parentSelector?.map(nodes)?.throwIfNeeded()

    val inputNodes = parentResult?.nodes ?: nodes
    return selector(inputNodes).throwIfNeeded()
  }

  private fun SelectionResult.throwIfNeeded(): SelectionResult {
    if (throwOnSelectionError && selectionError != null) throw AssertionError(selectionError)
    return this
  }
}

class SelectionResult(val nodes: List<TestNode>, internal val selectionError: String? = null)

internal fun testNodeSelector(matcher: TestNodeMatcher): TestNodeSelector {
  return TestNodeSelector(matcher.description) { nodes ->
    val filteredNodes = nodes.filter(matcher::matches)
    SelectionResult(filteredNodes)
  }
}

/**
 * Should be used for secondary selectors of a [TestNodeSelection] as they would typically expect a
 * single node as their input.
 */
internal fun TestNodeSelector.plusSingleToManySelector(
    description: String,
    newSelector: (TestNode) -> List<TestNode>
): TestNodeSelector {
  return TestNodeSelector(
      description = "${this.description} => $description",
      parentSelector = this,
      throwOnSelectionError = true,
      selector = { nodes ->
        if (nodes.size == 1) SelectionResult(newSelector(nodes[0]))
        else
            SelectionResult(
                emptyList(),
                buildCountMismatchError(
                    failedAssertionMessage = "Failed selection",
                    // Using parent selector to build this message since the bad input was from
                    // there and not really a result of the current selector
                    selector = this@plusSingleToManySelector,
                    expectedCount = 1,
                    matchedNodes = nodes),
            )
      })
}

/**
 * Should be used for secondary selectors of a [TestNodeCollectionSelection] as they would typically
 * expect a collection of test nodes as their input.
 */
internal fun TestNodeSelector.plusManyToSingleSelector(
    description: String,
    newSelector: (List<TestNode>) -> TestNode?
): TestNodeSelector {
  return TestNodeSelector(
      description = "${this.description} => $description",
      parentSelector = this,
      throwOnSelectionError = false,
      selector = { nodes ->
        when (val node = newSelector(nodes)) {
          null ->
              SelectionResult(
                  emptyList(),
                  buildNoMatchingNodeForSelectionError("Failed selection", this, nodes))
          else -> SelectionResult(listOf(node))
        }
      })
}

/**
 * Should be used for secondary selectors of a [TestNodeCollectionSelection] as they would typically
 * expect a collection of test nodes as their input.
 */
internal fun TestNodeSelector.plusManyToManySelector(matcher: TestNodeMatcher): TestNodeSelector {
  return TestNodeSelector(
      description = "${this.description} => ${matcher.description}",
      parentSelector = this,
      throwOnSelectionError = false,
      selector = { nodes ->
        val filteredNodes = nodes.filter { matcher.matches(it) }
        SelectionResult(filteredNodes)
      })
}

/**
 * Should be used for secondary selectors of a [TestNodeCollectionSelection] specifically for cases
 * where we simply need to pick a node at the given index in the collection.
 */
internal fun TestNodeSelector.plusIndexNodeSelector(
    index: Int,
): TestNodeSelector {
  return TestNodeSelector(
      description = "${this.description} => node at index $index",
      parentSelector = this,
      throwOnSelectionError = false,
      selector = { nodes ->
        when (val node = nodes.getOrNull(index)) {
          null -> SelectionResult(emptyList(), buildInvalidIndexError(this, index, nodes))
          else -> SelectionResult(listOf(node))
        }
      })
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import com.facebook.litho.Component
import com.facebook.litho.LithoView

class TestNodesListResolver {

  private val lithoViewComponentsTraverser = LithoViewComponentsTraverser()

  fun getCurrentTestNodes(lithoView: LithoView): List<TestNode> {
    val componentToTestTreeNode: MutableMap<Component, TestNode> = mutableMapOf()

    lithoViewComponentsTraverser.traverse(lithoView) { component, parentComponent ->
      // 1. create the test data
      val componentTestNode = TestNode(component)

      // 2. store the association between component and test node
      componentToTestTreeNode[component] = componentTestNode

      // 3. attempt to store parent data if exists
      val parentTestNode = parentComponent?.let { componentToTestTreeNode[it] }
      componentTestNode.parent = parentTestNode

      // 4. attempt to update children's of the parent
      if (parentTestNode != null) {
        val siblings = parentTestNode.children + componentTestNode
        parentTestNode.children = siblings
      }
    }

    return componentToTestTreeNode.values.toList()
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.assertj

import android.content.Context
import androidx.annotation.StringRes
import androidx.test.core.app.ApplicationProvider
import com.facebook.litho.Component
import com.facebook.litho.LithoView
import com.facebook.litho.testing.TestCollection
import com.facebook.litho.testing.TestCollectionItem
import com.facebook.litho.testing.viewtree.ViewPredicates
import com.facebook.litho.testing.viewtree.ViewTree
import org.assertj.core.api.AbstractAssert
import org.assertj.core.api.Assertions

class TestCollectionAssert(testCollection: TestCollection) :
    AbstractAssert<TestCollectionAssert, TestCollection>(
        testCollection, TestCollectionAssert::class.java) {

  /** Assert the number of items in the collection. */
  fun hasSize(size: Int): TestCollectionAssert {
    Assertions.assertThat(actual.items).hasSize(size)
    return this
  }

  /** Assert a condition on a child at a given index. */
  fun onChild(withIndex: Int, condition: (TestCollectionItem) -> Boolean): TestCollectionAssert {
    Assertions.assertThat(condition(actual.items[withIndex])).isTrue
    return this
  }

  /** Assert a condition on a child with a given id. */
  fun onChild(withId: Any, condition: (TestCollectionItem) -> Boolean): TestCollectionAssert {
    Assertions.assertThat(condition(actual.items.first { it.id == withId })).isTrue
    return this
  }

  /** Assert a condition on the first child. */
  fun onFirstChild(condition: (TestCollectionItem) -> Boolean): TestCollectionAssert {
    Assertions.assertThat(condition(actual.items.first())).isTrue
    return this
  }

  /** Assert a condition on the last child. */
  fun onLastChild(condition: (TestCollectionItem) -> Boolean): TestCollectionAssert {
    Assertions.assertThat(condition(actual.items.last())).isTrue
    return this
  }

  /** Assert a condition on every item. */
  fun onChildren(condition: (List<TestCollectionItem>) -> Boolean): TestCollectionAssert {
    Assertions.assertThat(condition(actual.items)).isTrue
    return this
  }

  /** Assert a condition on all items filtered by the given predicate. */
  fun onChildren(
      matching: (TestCollectionItem) -> Boolean,
      condition: (List<TestCollectionItem>) -> Boolean
  ): TestCollectionAssert {
    Assertions.assertThat(condition(actual.items.filter(matching))).isTrue
    return this
  }

  /**
   * Assert the presence of equivalent components. Child parameters like `id` and `isSticky` are not
   * compared.
   */
  fun containsComponents(vararg components: Component): TestCollectionAssert {
    components.forEach { component ->
      val equivalentComponent = actual.items.firstOrNull { it.component.isEquivalentTo(component) }
      Assertions.assertThat(equivalentComponent).isNotNull
    }
    return this
  }

  /**
   * Assert the absence of equivalent components. Child parameters like `id` and`isSticky` are not
   * compared.
   */
  fun doesNotContainComponents(vararg components: Component): TestCollectionAssert {
    components.forEach { component ->
      val equivalentComponent = actual.items.firstOrNull { it.component.isEquivalentTo(component) }
      Assertions.assertThat(equivalentComponent).isNull()
    }
    return this
  }

  /**
   * Assert the components are exactly equivalent to the given list. Child parameters like `id` and
   * `isSticky` are not compared.
   */
  fun containsExactlyComponents(vararg components: Component): TestCollectionAssert {
    actual.items.zip(components).forEach { (a, b) ->
      Assertions.assertThat(a.component.isEquivalentTo(b)).isTrue
    }
    return this
  }

  /** Assert visible views within the collection has the [text] as its text. */
  fun hasVisibleText(text: String): TestCollectionAssert {
    Assertions.assertThat(actual.getLithoViews().hasVisibleText(text))
        .overridingErrorMessage("Expected visible text \"<%s>\", but was not found.", text)
        .isTrue
    return this
  }

  /** Assert visible views within the collection has the [textRes]'s text as its text. */
  fun hasVisibleText(@StringRes textRes: Int): TestCollectionAssert =
      hasVisibleText(
          ApplicationProvider.getApplicationContext<Context>().getResources().getString(textRes))

  /** Assert visible views within the collection does not have the [text] as its text. */
  fun doesNotHaveVisibleText(text: String): TestCollectionAssert {
    Assertions.assertThat(actual.getLithoViews().hasVisibleText(text))
        .overridingErrorMessage("Did not expect visible text \"<%s>\"", text)
        .isFalse
    return this
  }

  /** Assert visible views within the collection does not have the [textRes]'s text as its text. */
  fun doesNotHaveVisibleText(@StringRes textRes: Int): TestCollectionAssert =
      doesNotHaveVisibleText(
          ApplicationProvider.getApplicationContext<Context>().getResources().getString(textRes))

  private fun LithoView.hasVisibleText(text: String): Boolean {
    val viewTree = ViewTree.of(this)
    val children =
        viewTree.findChild(ViewPredicates.hasVisibleText(text), ViewPredicates.isVisible())
    return children?.isNotEmpty() == true
  }

  private fun List<LithoView>.hasVisibleText(text: String): Boolean {
    for (view in this) {
      if (view.hasVisibleText(text)) {
        return true
      }
    }
    return false
  }

  companion object {
    fun assertThat(actual: TestCollection): TestCollectionAssert = TestCollectionAssert(actual)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.assertj

import com.facebook.litho.testing.TestCollectionItem
import org.assertj.core.api.AbstractAssert
import org.assertj.core.api.Assertions.assertThat

class TestCollectionItemAssert(testCollectionItem: TestCollectionItem) :
    AbstractAssert<TestCollectionItemAssert, TestCollectionItem>(
        testCollectionItem, TestCollectionItemAssert::class.java) {

  /** Assert the item is visible. */
  fun isVisible(): TestCollectionItemAssert {
    assertThat(actual.isVisible).overridingErrorMessage("Expected item to be visible").isTrue
    return this
  }

  /** Assert the item is not visible */
  fun isNotVisible(): TestCollectionItemAssert {
    assertThat(actual.isVisible).overridingErrorMessage("Expected item to not be visible").isFalse
    return this
  }

  /** Assert the item is fully visible. */
  fun isFullyVisible(): TestCollectionItemAssert {
    assertThat(actual.isFullyVisible)
        .overridingErrorMessage(
            "Expected item to be fully visible but is not.  The item ${if (actual.isVisible) "is" else "also is not "} partially visible")
        .isTrue
    return this
  }

  /** Assert that item is not fully visible. */
  fun isNotFullyVisible(): TestCollectionItemAssert {
    assertThat(actual.isFullyVisible)
        .overridingErrorMessage("Expected item to not be fully visible but is.")
        .isFalse
    return this
  }

  /** Assert that item is at index [index] */
  fun hasIndex(index: Int): TestCollectionItemAssert {
    assertThat(actual.index)
        .overridingErrorMessage("Expected item to be at index $index but was ${actual.index}")
        .isEqualTo(index)
    return this
  }

  companion object {
    @JvmStatic
    fun assertThat(actual: TestCollectionItem): TestCollectionItemAssert =
        TestCollectionItemAssert(actual)
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.componentsfinder

import com.facebook.litho.Component
import com.facebook.litho.LithoLayoutResult
import com.facebook.litho.LithoNode
import com.facebook.litho.LithoView
import com.facebook.litho.NestedTreeHolderResult
import com.facebook.litho.ScopedComponentInfo
import kotlin.reflect.KClass

/**
 * Returns the root component of the LithoView.
 *
 * example:
 * ```
 * class FBStory : KComponent() {
 * override fun ComponentScope.render() {
 * return Story()
 * }
 * ```
 *
 * `findRootComponentInLithoView` will only be able to return `FBStory` component, from a LithoView
 * with FBStory as root. If you would like to be able to find the `Story` component, see
 * [findDirectComponentInLithoView]
 */
fun getRootComponentInLithoView(lithoView: LithoView): Component? {
  val internalNode = getLayoutRoot(lithoView)?.node ?: return null
  return if (internalNode.scopedComponentInfos.isNotEmpty()) internalNode.headComponent else null
}

/**
 * Returns a component of the given class only if the component renders directly to the root
 * component of the LithoView
 *
 * example:
 * ```
 * class FBStory : KComponent() {
 * override fun ComponentScope.render() {
 * return Story()
 * }
 * ```
 *
 * findDirectComponentInLithoView will only be able to return Story Component from the LithoView
 * with FBStory as a root
 *
 * ```
 * class FBStory : KComponent() {
 * override fun ComponentScope.render() {
 * return Column{
 *    child(CommentComponent())
 *    child(AuthorComponent())
 *    }
 * }
 * findDirectComponentInLithoView here will only be able to return Column
 * ```
 */
fun findDirectComponentInLithoView(lithoView: LithoView, clazz: Class<out Component?>): Component? {
  val internalNode = getLayoutRoot(lithoView)?.node ?: return null
  return getOrderedScopedComponentInfos(internalNode)
      .map { it.component }
      .firstOrNull { it.javaClass == clazz }
}

/**
 * Returns a component of the given class only if the component renders directly to the root
 * component of the LithoView (for details see: [findDirectComponentInLithoView])
 */
fun findDirectComponentInLithoView(lithoView: LithoView, clazz: KClass<out Component>): Component? {
  return findDirectComponentInLithoView(lithoView, clazz.java)
}

/** Returns a component of the given class from the ComponentTree or null if not found */
fun findComponentInLithoView(lithoView: LithoView, clazz: Class<out Component?>): Component? {
  val layoutRoot = getLayoutRoot(lithoView) ?: return null
  return findComponentViaBreadthFirst(clazz, layoutRoot)
}

/** Returns a component of the given class from the ComponentTree or null if not found */
fun findComponentInLithoView(lithoView: LithoView, clazz: KClass<out Component>): Component? {
  return findComponentInLithoView(lithoView, clazz.java)
}

fun findAllDirectComponentsInLithoView(
    lithoView: LithoView,
    clazz: KClass<out Component>
): List<Component> {
  return findAllDirectComponentsInLithoView(lithoView, clazz.java)
}

fun findAllDirectComponentsInLithoView(
    lithoView: LithoView,
    clazz: Class<out Component>
): List<Component> {
  val internalNode = getLayoutRoot(lithoView)?.node ?: return emptyList()
  return getOrderedScopedComponentInfos(internalNode)
      .map { it.component }
      .filter { it.javaClass == clazz }
}

/**
 * Returns a list of all components of the given classes from the ComponentTree or an empty list if
 * not found
 */
fun findAllComponentsInLithoView(
    lithoView: LithoView,
    vararg clazz: Class<out Component?>
): List<Component> {
  val layoutResult = getLayoutRoot(lithoView) ?: return emptyList()
  return findAllComponentsViaBreadthFirstSearch(clazz, layoutResult)
}

/**
 * Returns a list of all components of the given classes from the ComponentTree or an empty list if
 * not found
 */
fun findAllComponentsInLithoView(
    lithoView: LithoView,
    vararg clazz: KClass<out Component>
): List<Component> {
  val javaClasses = Array(clazz.size) { i -> clazz[i].java }
  return findAllComponentsInLithoView(lithoView, *javaClasses)
}

private fun getLayoutRoot(lithoView: LithoView): LithoLayoutResult? {
  val commitedLayoutState =
      lithoView.componentTree?.committedLayoutState
          ?: throw IllegalStateException(
              "No ComponentTree/Committed Layout/Layout Root found. Please call render() first")
  return commitedLayoutState.rootLayoutResult
}

/**
 * Goes through nodes in a component tree via breadth first search. Returns a component of a given
 * class or null if not found
 */
private fun findComponentViaBreadthFirst(
    clazz: Class<out Component?>,
    layoutResult: LithoLayoutResult?
): Component? {
  componentBreadthFirstSearch(layoutResult) { scopedComponents ->
    val foundComponent = scopedComponents.firstOrNull { it.javaClass == clazz }
    if (foundComponent != null) {
      return foundComponent
    }
  }

  return null
}

/**
 * Goes through nodes in a component tree via breadth first search. Returns all components that
 * match the given classes or an empty list if none found
 */
private fun findAllComponentsViaBreadthFirstSearch(
    clazzArray: Array<out Class<out Component?>>,
    layoutResult: LithoLayoutResult?,
): List<Component> {
  val foundComponentsList = mutableListOf<Component>()

  componentBreadthFirstSearch(layoutResult) { scopedComponents ->
    val foundComponents = scopedComponents.filter { it.javaClass in clazzArray }
    foundComponentsList.addAll(foundComponents)
  }

  return foundComponentsList
}

/**
 * Internal function to handle BFS through a set of components.
 *
 * @param onHandleScopedComponents lambda which handles the scoped components of the particular
 *   layout. This enables the caller of the function to properly handle if any of those components
 *   match. (For example, if looking for a single component, you would want to return in the lambda
 *   if it matches. If looking for multiple, you would simply want to add all matching components to
 *   a list.)
 */
private inline fun componentBreadthFirstSearch(
    startingLayoutResult: LithoLayoutResult?,
    onHandleScopedComponents: (List<Component>) -> Unit
) {
  startingLayoutResult ?: return

  val visited = mutableSetOf(startingLayoutResult)
  val queue = ArrayDeque(visited)

  while (queue.isNotEmpty()) {
    val current = queue.removeFirst()
    onHandleScopedComponents(getOrderedScopedComponentInfos(current.node).map { it.component })

    if (current is NestedTreeHolderResult) {
      val nestedResult = current.nestedResult?.takeUnless { it in visited } ?: continue
      queue.add(nestedResult)
      continue
    }

    for (i in 0 until current.childCount) {
      val child = current.getChildAt(i).takeUnless { it in visited } ?: continue
      queue.add(child)
    }
  }
}

/**
 * @return list of the [ScopedComponentInfo]s, ordered from the head (closest to the root) to the
 *   tail.
 */
private fun getOrderedScopedComponentInfos(internalNode: LithoNode): List<ScopedComponentInfo> {
  return internalNode.scopedComponentInfos.reversed()
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing

import android.graphics.drawable.GradientDrawable
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.facebook.litho.Column
import com.facebook.litho.Component
import com.facebook.litho.Row
import com.facebook.litho.Style
import com.facebook.litho.core.height
import com.facebook.litho.core.heightPercent
import com.facebook.litho.core.widthPercent
import com.facebook.litho.eventHandlerWithReturn
import com.facebook.litho.kotlin.widget.Text
import com.facebook.litho.px
import com.facebook.litho.sections.SectionContext
import com.facebook.litho.sections.common.DataDiffSection
import com.facebook.litho.sections.widget.RecyclerCollectionComponent
import com.facebook.litho.testing.testrunner.LithoTestRunner
import com.facebook.litho.viewcompat.SimpleViewBinder
import com.facebook.litho.viewcompat.ViewCreator
import com.facebook.litho.widget.ComponentRenderInfo
import com.facebook.litho.widget.ViewRenderInfo
import com.facebook.litho.widget.collection.LazyList
import org.assertj.core.api.Assertions.assertThat
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Answers
import org.mockito.kotlin.mock

@Ignore("This test stopped working when run against API 16") // TODO: T138559546
@RunWith(LithoTestRunner::class)
class TestCollectionDescriptionHelperTest {

  @Rule @JvmField val lithoViewRule = LithoViewRule()

  @Test
  fun `prints just recycler string when no children`() {
    val testLithoView = lithoViewRule.render(widthPx = 100, heightPx = 100) { LazyList {} }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .isEqualTo(testCollection.recyclerView.toString())
  }

  @Test
  fun `prints recycler with fully visible child item info with id`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          LazyList {
            child(id = "identifier", deps = emptyArray()) { Column(style = Style.height(50.px)) }
          }
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(3)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: identifier, visibility: full\)
          |      litho.Column\{\w+ V.E..... .. 0,0-100,50\}
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints recycler with partially visible child item info with id`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          LazyList {
            child(id = "identifier", deps = emptyArray()) { Column(style = Style.height(150.px)) }
          }
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(3)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: identifier, visibility: partial\)
          |      litho.Column\{\w+ V.E..... .. 0,0-100,150\}
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints recycler with invisible child item info without id set`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          LazyList { child(Column(style = Style.height(0.px))) }
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(3)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: \d+:1, visibility: none\)
          |      litho.Column\{\w+ V.E..... .. 0,0-100,0\}
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints recycler with multiple children including fallback`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          LazyList {
            child(Column(style = Style.height(10.px)))
            child(id = "row", component = Row(style = Style.height(100.px)))
            child(Text(text = "word", style = Style.height(10.px)))
          }
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(9)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: \d+:1, visibility: full\)
          |      litho.Column\{\w+ V.E..... .. 0,0-100,10\}
          |
          |â””â”€â”€ index 1: Collection Item \(id: row, visibility: partial\)
          |      litho.Row\{\w+ V.E..... .. 0,0-100,100\}
          |
          |â””â”€â”€ index 2: Collection Item \(id: \d+:1, visibility: none\)
          |      Text\{\w+\}
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints sections recycler with mock components`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          RecyclerCollectionComponent(
              style = Style.widthPercent(100f).heightPercent(100f),
              section =
                  DataDiffSection.create<Int>(SectionContext(context))
                      .data(listOf(100, 200))
                      .renderEventHandler(
                          eventHandlerWithReturn {
                            if (it.index == 0)
                            // Delegates to DebugComponent still
                            ComponentRenderInfo.create().component(mock<Component>()).build()
                            else
                            // Skips calling debug component
                            mock<ComponentRenderInfo>(defaultAnswer = Answers.RETURNS_DEEP_STUBS)
                          })
                      .build())
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(6)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: null, visibility: none\)
          |      litho.null\{\w+ V.E..... .. 0,0-100,0\}
          |
          |â””â”€â”€ index 1: Collection Item \(id: null, visibility: none\)
          |      Component${ESCAPED_DOLLAR_SIGN}MockitoMock${ESCAPED_DOLLAR_SIGN}\w+\{\w+\}
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints sections recycler with TextView child`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          RecyclerCollectionComponent(
              style = Style.widthPercent(100f).heightPercent(100f),
              section =
                  DataDiffSection.create<Int>(SectionContext(context))
                      .data(listOf(100))
                      .renderEventHandler(
                          eventHandlerWithReturn {
                            ViewRenderInfo.create()
                                .viewCreator(VIEW_CREATOR)
                                .viewBinder(
                                    object : SimpleViewBinder<LinearLayout>() {
                                      override fun bind(view: LinearLayout) {
                                        view.addView(
                                            TextView(view.context).apply {
                                              text = "An embedded view! ${it.model}"
                                              textSize = 16f
                                              layoutParams =
                                                  ViewGroup.LayoutParams(
                                                      ViewGroup.LayoutParams.WRAP_CONTENT,
                                                      ViewGroup.LayoutParams.WRAP_CONTENT)
                                            })
                                      }
                                    })
                                .build()
                          })
                      .build())
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(4)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: null, visibility: full\)
          |      LinearLayout@\w+
          |        TextView@\w+ \(Found text: "An embedded view! 100", view is visible\)
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints sections recycler with ImageView child`() {
    val gradientDrawable = GradientDrawable()

    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          RecyclerCollectionComponent(
              style = Style.widthPercent(100f).heightPercent(100f),
              section =
                  DataDiffSection.create<Int>(SectionContext(context))
                      .data(listOf(100))
                      .renderEventHandler(
                          eventHandlerWithReturn {
                            ViewRenderInfo.create()
                                .viewCreator(VIEW_CREATOR)
                                .viewBinder(
                                    object : SimpleViewBinder<LinearLayout>() {
                                      override fun bind(view: LinearLayout) {
                                        view.addView(
                                            ImageView(view.context).apply {
                                              setImageDrawable(gradientDrawable)
                                              layoutParams =
                                                  ViewGroup.LayoutParams(
                                                      ViewGroup.LayoutParams.WRAP_CONTENT,
                                                      ViewGroup.LayoutParams.WRAP_CONTENT)
                                            })
                                      }
                                    })
                                .build()
                          })
                      .build())
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(4)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: null, visibility: full\)
          |      LinearLayout@\w+
          |        ImageView@\w+ \(Found drawable: "$gradientDrawable", view is visible\)
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints sections recycler with views where some are not visible`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          RecyclerCollectionComponent(
              style = Style.widthPercent(100f).heightPercent(100f),
              section =
                  DataDiffSection.create<Int>(SectionContext(context))
                      .data(listOf(100, 200, 300))
                      .renderEventHandler(
                          eventHandlerWithReturn {
                            ViewRenderInfo.create()
                                .viewCreator(VIEW_CREATOR)
                                .viewBinder(
                                    object : SimpleViewBinder<LinearLayout>() {
                                      override fun bind(view: LinearLayout) {
                                        view.addView(
                                            TextView(view.context).apply {
                                              text = "An embedded view! ${it.model}"
                                              textSize = 16f
                                              layoutParams =
                                                  ViewGroup.LayoutParams(
                                                      ViewGroup.LayoutParams.WRAP_CONTENT, 55)
                                            })
                                      }
                                    })
                                .build()
                          })
                      .build())
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(11)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: null, visibility: full\)
          |      LinearLayout@\w+
          |        TextView@\w+ \(Found text: "An embedded view! 100", view is visible\)
          |
          |â””â”€â”€ index 1: Collection Item \(id: null, visibility: partial\)
          |      LinearLayout@\w+
          |        TextView@\w+ \(Found text: "An embedded view! 200", view is visible\)
          |
          |â””â”€â”€ index 2: Collection Item \(id: null, visibility: none\)
          |      Found null item view \(no additional information available\)
          |
        """
                .trimMargin())
  }

  @Test
  fun `prints sections recycler with mixed view and component children`() {
    val testLithoView =
        lithoViewRule.render(widthPx = 100, heightPx = 100) {
          RecyclerCollectionComponent(
              style = Style.widthPercent(100f).heightPercent(100f),
              section =
                  DataDiffSection.create<Int>(SectionContext(context))
                      .data(listOf(0, 1))
                      .renderEventHandler(
                          eventHandlerWithReturn {
                            if (it.index == 0) {
                              return@eventHandlerWithReturn ComponentRenderInfo.create()
                                  .component(Text(text = "A Litho Component: ${it.index}"))
                                  .build()
                            }

                            ViewRenderInfo.create()
                                .viewCreator(VIEW_CREATOR)
                                .viewBinder(
                                    object : SimpleViewBinder<LinearLayout>() {
                                      override fun bind(view: LinearLayout) {
                                        view.addView(
                                            TextView(view.context).apply {
                                              text = "An embedded view! ${it.model}"
                                              textSize = 16f
                                              layoutParams =
                                                  ViewGroup.LayoutParams(
                                                      ViewGroup.LayoutParams.WRAP_CONTENT,
                                                      ViewGroup.LayoutParams.WRAP_CONTENT)
                                            })
                                      }
                                    })
                                .build()
                          })
                      .build())
        }
    val testCollection = testLithoView.findCollectionComponent()!!

    assertThat(TestCollectionDescriptionHelper.collectionToString(testCollection))
        .startsWith(testCollection.recyclerView.toString())
        .hasLineCount(7)
        .matches(
            """
          |${testCollection.recyclerView}
          |â””â”€â”€ index 0: Collection Item \(id: null, visibility: full\)
          |      litho.Text\{\w+ V.E..... .. 0,0-100,\d+ text="A Litho Component: 0"\}
          |
          |â””â”€â”€ index 1: Collection Item \(id: null, visibility: full\)
          |      LinearLayout@\w+
          |        TextView@\w+ \(Found text: "An embedded view! 1", view is visible\)
          |
        """
                .trimMargin())
  }

  private companion object {
    const val ESCAPED_DOLLAR_SIGN = "\\$"

    private val VIEW_CREATOR: ViewCreator<LinearLayout> =
        ViewCreator<LinearLayout> { c, _ ->
          LinearLayout(c).apply {
            layoutParams =
                ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
          }
        }
  }
}


/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * 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.
 */

package com.facebook.litho.testing.api

import com.facebook.litho.Column
import com.facebook.litho.Component
import com.facebook.litho.ComponentScope
import com.facebook.litho.KComponent
import com.facebook.litho.Row
import com.facebook.litho.Style
import com.facebook.litho.flexbox.flex
import com.facebook.litho.kotlin.widget.Text
import com.facebook.litho.testing.testrunner.LithoTestRunner
import com.facebook.litho.view.testKey
import com.facebook.litho.widget.Image
import com.facebook.litho.widget.Text
import com.facebook.litho.widget.collection.LazyList
import org.assertj.core.api.Assertions
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.LooperMode

@LooperMode(LooperMode.Mode.LEGACY)
@RunWith(LithoTestRunner::class)
class AssertionErrorsTest : RunWithDebugInfoTest() {

  @get:Rule val rule: LithoRule = LithoRule()

  @Test
  fun `TestNodeSelection assertion with multiple test node result`() {
    Assertions.assertThatThrownBy {
          rule.render { CollectionComponent() }.selectNode(hasType<Text>()).assertExists()
        }
        .hasMessage(
            """
            Failed: assertExists
            Reason: Expected exactly 1 node(s), but found 6
            Node(s) found:
            -Text
               isEnabled = false
               text = Hello world
            -Text
               testKey = item-#0
               isEnabled = false
               text = Item #0
            -Text
               testKey = item-#1
               isEnabled = false
               text = Item #1
            -Text
               testKey = item-#2
               isEnabled = false
               text = Item #2
            -Text
               testKey = item-#3
               isEnabled = false
               text = Item #3
            -Text
               testKey = item-#4
               isEnabled = false
               text = Item #4
            Selector used: is a component of type com.facebook.litho.widget.Text

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeSelection assertion with no test node result`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNode(hasType<Image>())
              .assert(hasAncestor(hasType<CollectionComponent>()))
        }
        .hasMessage(
            """
            Failed assertion: has ancestor that is a component of type com.facebook.litho.testing.api.AssertionErrorsTest.CollectionComponent
            Reason: Could not find any matching node for selection
            Selector used: is a component of type com.facebook.litho.widget.Image

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeSelection generic assertion`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNode(hasType<Row>())
              .assert(hasChild(hasType<Text>()) and hasTestKey("invalid"))
        }
        .hasMessage(
            """
            Failed assertion: has child that is a component of type com.facebook.litho.widget.Text and has test key "invalid"
            -Row(children=1)
               isEnabled = false
             |-Text
                isEnabled = false
                text = Hello world
            Selector used: is a component of type com.facebook.litho.Row

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeCollectionSelection assertExist on empty result`() {
    Assertions.assertThatThrownBy {
          rule.render { CollectionComponent() }.selectNodes(hasTestKey("no-result")).assertExists()
        }
        .hasMessage(
            """
            Failed: assertExists
            Reason: Could not find any matching node for selection
            Selector used: has test key "no-result"

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeSelection assertion with invalid index selection`() {
    // Typically, if a node is not found, we simply show a missing node error
    // However, in the case where the node is not available specifically because it was
    // a selection on a bad index, we should use this information as it'll be way more
    // helpful than simply failing with the typical missing node error
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNodes(hasType<Text>())
              .selectAtIndex(10)
              .assertExists()
        }
        .hasMessage(
            """
            Failed: assertExists
            Failed selection: index is out of bounds
            Reason: Requested a node at index 10, but only 6 nodes are available
            Node(s) found:
            -Text
               isEnabled = false
               text = Hello world
            -Text
               testKey = item-#0
               isEnabled = false
               text = Item #0
            -Text
               testKey = item-#1
               isEnabled = false
               text = Item #1
            -Text
               testKey = item-#2
               isEnabled = false
               text = Item #2
            -Text
               testKey = item-#3
               isEnabled = false
               text = Item #3
            -Text
               testKey = item-#4
               isEnabled = false
               text = Item #4
            Selector used: is a component of type com.facebook.litho.widget.Text => node at index 10

            """
                .trimIndent())
  }

  @Test
  fun `secondary selection with original selection error - v1`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNode(hasType<Image>()) // <- culprit: invalid matcher
              .selectChildren()
              .assertExists()
        }
        .hasMessage(
            """
            Failed selection
            Reason: Expected exactly 1 node(s), but found 0
            Selector used: is a component of type com.facebook.litho.widget.Image

            """
                .trimIndent())
  }

  @Test
  fun `secondary selection with original selection error - v2`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNodes(hasType<Column>())
              .selectAtIndex(2) // <- culprit: invalid index
              .selectChildren()
              .assertExists()
        }
        .hasMessage(
            """
            Failed selection: index is out of bounds
            Reason: Requested a node at index 2, but only 1 node is available
            Node(s) found:
            -Column(children=2)
               isEnabled = false
            Selector used: is a component of type com.facebook.litho.Column => node at index 2

            """
                .trimIndent())
  }

  @Test
  fun `select first on an empty collection`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNodes(hasType<Image>())
              .selectFirst()
              .assertExists()
        }
        .hasMessage(
            """
            Failed: assertExists
            Failed selection
            Reason: Could not find any matching node for selection
            Selector used: is a component of type com.facebook.litho.widget.Image => first node

            """
                .trimIndent())
  }

  @Test
  fun `select one with more than one result`() {
    Assertions.assertThatThrownBy {
          // A more compact version of this without the secondary select is simply:
          // selectNode(hasTextContaining("Item"))
          rule
              .render { CollectionComponent() }
              .selectNodes(hasTextContaining("Item"))
              .select(TestNodeMatcher("single node") { true })
              .assertExists()
        }
        .hasMessage(
            """
            Failed: assertExists
            Reason: Expected exactly 1 node(s), but found 5
            Node(s) found:
            -Text
               testKey = item-#0
               isEnabled = false
               text = Item #0
            -Text
               testKey = item-#1
               isEnabled = false
               text = Item #1
            -Text
               testKey = item-#2
               isEnabled = false
               text = Item #2
            -Text
               testKey = item-#3
               isEnabled = false
               text = Item #3
            -Text
               testKey = item-#4
               isEnabled = false
               text = Item #4
            Selector used: has text containing "Item" => single node

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeCollectionSelection assertAll`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNodes(hasTextContaining("Item"))
              .assertAll(hasTestKey("item-#0"))
        }
        .hasMessage(
            """
            Failed: assertAll(has test key "item-#0")
            Reason: The following nodes do not match the expected condition:
            -Text
               testKey = item-#1
               isEnabled = false
               text = Item #1
            -Text
               testKey = item-#2
               isEnabled = false
               text = Item #2
            -Text
               testKey = item-#3
               isEnabled = false
               text = Item #3
            -Text
               testKey = item-#4
               isEnabled = false
               text = Item #4
            Selector used: has text containing "Item"

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeCollectionSelection assertAny`() {
    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNodes(hasTextContaining("Item"))
              .assertAny(hasType<Image>())
        }
        .hasMessage(
            """
            Failed: assertAny(is a component of type com.facebook.litho.widget.Image)
            Reason: None of the selected nodes match the expected condition
            Node(s) found:
            -Text
               testKey = item-#0
               isEnabled = false
               text = Item #0
            -Text
               testKey = item-#1
               isEnabled = false
               text = Item #1
            -Text
               testKey = item-#2
               isEnabled = false
               text = Item #2
            -Text
               testKey = item-#3
               isEnabled = false
               text = Item #3
            -Text
               testKey = item-#4
               isEnabled = false
               text = Item #4
            Selector used: has text containing "Item"

            """
                .trimIndent())
  }

  @Test
  fun `TestNodeCollectionSelection assertAny on empty collection`() {

    Assertions.assertThatThrownBy {
          rule
              .render { CollectionComponent() }
              .selectNodes(hasType<Image>())
              .assertAny(hasTextContaining("Item"))
        }
        .hasMessage(
            """
            Failed: assertAny(has text containing "Item")
            Reason: Could not find any matching node for selection
            Selector used: is a component of type com.facebook.litho.widget.Image

            """
                .trimIndent())
  }

  private class CollectionComponent : KComponent() {

    override fun ComponentScope.render(): Component {
      return Column(style = Style.flex(grow = 1f)) {
        child(Row { child(Text("Hello world")) })

        child(
            LazyList(style = Style.flex(grow = 1f)) {
              children(items = 0 until 5, id = { it }) {
                Text("Item #$it", Style.testKey("item-#$it"))
              }
            })
      }
    }
  }
}