//
//  DataBindingTests.swift
//  RiveRuntimeTests
//
//  Created by David Skuza on 1/15/25.
//  Copyright © 2025 Rive. All rights reserved.
//

import XCTest
@testable import RiveRuntime

class DataBindingTests: XCTestCase {
    var file: RiveFile!

    override func setUp() {
        file = try! RiveFile(testfileName: "data_binding_test")
    }

    override func tearDown() {
        file = nil
    }

    // MARK: - RiveFile

    func test_file_ViewModelAtIndex_atIndex_returnsViewModelOrNil() {
        XCTAssertNotNil(file.viewModel(at: 0))
        XCTAssertNotNil(file.viewModel(at: 1))
        XCTAssertNotNil(file.viewModel(at: 2))

        XCTAssertNil(file.viewModel(at: 3))
    }

    func testViewModel_named_returnsViewModelOrNil() {
        XCTAssertNotNil(file.viewModelNamed("Test"))
        XCTAssertNotNil(file.viewModelNamed("Nested"))

        XCTAssertNil(file.viewModelNamed("404"))
    }

    // MARK: - RiveDataBindingViewModel

    func test_viewModel_createInstance_fromIndex_returnsInstanceOrNil() {
        let viewModel = file.viewModelNamed("Test")!
        XCTAssertNotNil(viewModel.createInstance(fromIndex: 0))
        XCTAssertNotNil(viewModel.createInstance(fromIndex: 1))
        XCTAssertNil(viewModel.createInstance(fromIndex: 2))
    }

    func test_viewModel_createInstance_fromName_returnsInstanceOrNil() {
        let viewModel = file.viewModelNamed("Test")!
        XCTAssertNotNil(viewModel.createInstance(fromName: "Default"))
        XCTAssertNil(viewModel.createInstance(fromName: "404"))
    }

    func test_viewModel_createDefaultInstance_returnsInstance() {
        var viewModel = file.viewModelNamed("Test")
        XCTAssertNotNil(viewModel?.createDefaultInstance())

        viewModel = file.viewModelNamed("Default")
        XCTAssertNotNil(viewModel?.createDefaultInstance())
    }

    func test_viewModel_createInstance_returnsBlankInstance() {
        let viewModel = file.viewModelNamed("Test")!
        XCTAssertNotNil(viewModel.createInstance())
    }

    func test_viewModel_instanceCount_returnsNames() {
        var viewModel = file.viewModelNamed("Test")!
        XCTAssertEqual(viewModel.instanceCount, 2)

        // "Default" has no explicit instances; it only has the default created by the editor
        viewModel = file.viewModelNamed("Default")!
        XCTAssertEqual(viewModel.instanceCount, 1)
    }

    func test_viewModel_instanceNames_withInstances_returnsNames() {
        let viewModel = file.viewModelNamed("Test")!
        XCTAssertEqual(Set(viewModel.instanceNames), ["Editor Defaults", "Default"])
    }

    func test_viewModel_instanceNames_withoutInstances_returnsDefault() {
        let viewModel = file.viewModelNamed("Default")!
        XCTAssertEqual(viewModel.instanceNames, [""])
    }

    func test_viewModel_name_returnsName() {
        let viewModel = file.viewModelNamed("Test")!
        XCTAssertEqual(viewModel.name, "Test")
    }

    func test_viewModel_propertyCount_returnsCount() {
        let viewModel = file.viewModelNamed("Test")!
        XCTAssertEqual(viewModel.propertyCount, 12)
    }

    func test_viewModel_properties_returnsAllProperties() {
        let viewModel = file.viewModelNamed("Test")!

        var data = viewModel.properties[0]
        XCTAssertEqual(data.type, .list)
        XCTAssertEqual(data.name, "List")

        data = viewModel.properties[1]
        XCTAssertEqual(data.type, .assetImage)
        XCTAssertEqual(data.name, "Image")

        data = viewModel.properties[2]
        XCTAssertEqual(data.type, .viewModel)
        XCTAssertEqual(data.name, "SecondNested")

        data = viewModel.properties[3]
        XCTAssertEqual(data.type, .trigger)
        XCTAssertEqual(data.name, "Trigger Blue")

        data = viewModel.properties[4]
        XCTAssertEqual(data.type, .trigger)
        XCTAssertEqual(data.name, "Trigger Green")

        data = viewModel.properties[5]
        XCTAssertEqual(data.type, .trigger)
        XCTAssertEqual(data.name, "Trigger Red")

        data = viewModel.properties[6]
        XCTAssertEqual(data.type, .viewModel)
        XCTAssertEqual(data.name, "Nested")

        data = viewModel.properties[7]
        XCTAssertEqual(data.type, .enum)
        XCTAssertEqual(data.name, "Enum")

        data = viewModel.properties[8]
        XCTAssertEqual(data.type, .color)
        XCTAssertEqual(data.name, "Color")

        data = viewModel.properties[9]
        XCTAssertEqual(data.type, .boolean)
        XCTAssertEqual(data.name, "Boolean")

        data = viewModel.properties[10]
        XCTAssertEqual(data.type, .number)
        XCTAssertEqual(data.name, "Number")

        data = viewModel.properties[11]
        XCTAssertEqual(data.type, .string)
        XCTAssertEqual(data.name, "String")
    }

    // MARK: - RiveDataBindingViewModelInstance

    func test_viewModelInstance_name_returnsName() {
        let viewModel = file.viewModelNamed("Test")!

        var instance = viewModel.createInstance(fromName: "Default")
        XCTAssertEqual(instance?.name, "Default")

        instance = viewModel.createInstance(fromName: "Editor Defaults")
        XCTAssertEqual(instance?.name, "Editor Defaults")
    }

    // MARK: Property

    func test_viewModelInstance_property() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.property(fromPath: "String"))
        XCTAssertNil(instance.property(fromPath: "404"))
    }

    func test_viewModelInstance_property_onDealloc_removedFromStore() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        var property = instance.property(fromPath: "String")
        let observer = property?.observe(\.hasChanged) { _, _ in }
        observer?.invalidate()
        property = nil
    }

    // Tests _memory_ equality of objects to ensure they are properly cached
    // within their parent instance and reused.
    func test_viewModelInstance_properties_withoutPath_isCachedByRoot() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        
        let property = instance.property(fromPath: "String")!
        let cachedProperty = instance.property(fromPath: "String")!
        XCTAssertTrue(property === cachedProperty)

        let stringProperty = instance.stringProperty(fromPath: "String")!
        let cachedStringProperty = instance.stringProperty(fromPath: "String")!
        XCTAssertTrue(stringProperty === cachedStringProperty)

        let numberProperty = instance.numberProperty(fromPath: "Number")!
        let cachedNumberProperty = instance.numberProperty(fromPath: "Number")!
        XCTAssertTrue(numberProperty === cachedNumberProperty)

        let booleanProperty = instance.booleanProperty(fromPath: "Boolean")!
        let cachedBooleanProperty = instance.booleanProperty(fromPath: "Boolean")!
        XCTAssertTrue(booleanProperty === cachedBooleanProperty)

        let colorProperty = instance.colorProperty(fromPath: "Color")!
        let cachedColorProperty = instance.colorProperty(fromPath: "Color")!
        XCTAssertTrue(colorProperty === cachedColorProperty)

        let enumProperty = instance.enumProperty(fromPath: "Enum")!
        let cachedEnumProperty = instance.enumProperty(fromPath: "Enum")!
        XCTAssertTrue(enumProperty === cachedEnumProperty)

        let triggerProperty = instance.triggerProperty(fromPath: "Trigger Red")!
        let cachedTriggerProperty = instance.triggerProperty(fromPath: "Trigger Red")!
        XCTAssertTrue(triggerProperty === cachedTriggerProperty)
    }

    // Tests _memory_ equality of objects to ensure they are properly cached
    // within their parent instance and reused, where nested components are
    // cached by the correct child instance (thus becoming a property's parent).
    func test_viewModelInstance_properties_withPath_isCachedByNestedRoot() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let stringPropertyByPath = instance.stringProperty(fromPath: "Nested/String")!

        let stringPropertyFromNested = instance.viewModelInstanceProperty(fromPath: "Nested")?.stringProperty(fromPath: "String")

        let nested = instance.viewModelInstanceProperty(fromPath: "Nested")!
        let nestedStringProperty = nested.stringProperty(fromPath: "String")!

        XCTAssertTrue(stringPropertyByPath === stringPropertyFromNested)
        XCTAssertTrue(stringPropertyFromNested === nestedStringProperty)
    }

    // MARK: String

    func test_viewModelInstance_stringProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.stringProperty(fromPath: "String"))
        XCTAssertNil(instance.stringProperty(fromPath: "404"))
    }

    func test_viewModelInstance_stringProperty_setsValue() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.stringProperty(fromPath: "String")!
        XCTAssertEqual(property.value, "Text")

        // This, and similar tests below, verify that we are calling
        // into the c++ runtime to set / get the value.
        let newValue = "XCTest"
        property.value = newValue
        XCTAssertEqual(property.value, newValue)
    }

    // MARK: Number

    func test_viewModelInstance_numberProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.numberProperty(fromPath: "Number"))
        XCTAssertNil(instance.numberProperty(fromPath: "404"))
    }

    func test_viewModelInstance_numberProperty_setsValue() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.numberProperty(fromPath: "Number")!
        XCTAssertEqual(property.value, 0)

        let newValue: Float = 1337
        property.value = newValue
        XCTAssertEqual(property.value, newValue)
    }

    // MARK: Boolean

    func test_viewModelInstance_booleanProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.booleanProperty(fromPath: "Boolean"))
        XCTAssertNil(instance.booleanProperty(fromPath: "404"))
    }

    func test_viewModelInstance_booleanProperty_setsValue() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.booleanProperty(fromPath: "Boolean")!
        XCTAssertEqual(property.value, false)

        let newValue = true
        property.value = newValue
        XCTAssertEqual(property.value, newValue)
    }

    // MARK: Color

    func test_viewModelInstance_colorProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.colorProperty(fromPath: "Color"))
        XCTAssertNil(instance.colorProperty(fromPath: "404"))
    }

    func test_viewModelInstance_colorProperty_setsValue() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.colorProperty(fromPath: "Color")!
        XCTAssertEqual(Color(property.value), Color(.black))

        let updatedColor = UIColor(red: 0x1D / 255, green: 0x1D / 255, blue: 0x1D / 255, alpha: 0x1D / 255)
        property.value = updatedColor
        XCTAssertEqual(Color(property.value), Color(updatedColor))

        property.set(red: 0, green: 0, blue: 0, alpha: 0xFF)
        XCTAssertEqual(Color(property.value), Color(.black))

        // Test clamping floats 0...1

        property.set(red: 2, green: 2, blue: 2, alpha: 2)
        XCTAssertEqual(Color(property.value), Color(.white))

        property.set(red: -1, green: -1, blue: -1, alpha: -1)
        XCTAssertEqual(Color(property.value), Color(.clear))

        // "Reset" for the next test by testing alpha

        property.setAlpha(1)
        XCTAssertEqual(Color(property.value), Color(.black))

        property.set(red: 0xFF / 255, green: 0xFF / 255, blue: 0xFF / 255)
        XCTAssertEqual(Color(property.value), Color(.white))

        // Test clamping floats 0...1
        // "Resets" the color to white so that we don't
        // have to test against an alpha value
        property.value = .white

        property.set(red: 2, green: 2, blue: 2)
        XCTAssertEqual(Color(property.value), Color(.white))

        property.set(red: -1, green: -1, blue: -1)
        XCTAssertEqual(Color(property.value), Color(.black))
    }

    // MARK: Enum

    func test_viewModelInstance_enumProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.enumProperty(fromPath: "Enum"))
        XCTAssertNil(instance.enumProperty(fromPath: "404"))
    }

    func test_viewModelInstance_enumProperty_containsAllValues() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.enumProperty(fromPath: "Enum")!
        XCTAssertEqual(Set(property.values), Set(["Foo", "Bar", "Baz"]))
    }

    func test_viewModelInstance_enumProperty_setsValue() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.enumProperty(fromPath: "Enum")!
        XCTAssertEqual(property.value, "Foo")

        var newValue = "Bar"
        property.value = newValue
        XCTAssertEqual(property.value, newValue)

        newValue = "Baz"
        property.value = newValue
        XCTAssertEqual(property.value, newValue)

        newValue = "404"
        property.value = newValue
        XCTAssertEqual(property.value, "Baz")
    }

    func test_viewModelInstance_enumProperty_setsValueIndex() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.enumProperty(fromPath: "Enum")!
        XCTAssertEqual(property.value, "Foo")
        XCTAssertEqual(property.valueIndex, 2)

        var newValue = "Bar"
        property.value = newValue
        XCTAssertEqual(property.value, newValue)
        XCTAssertEqual(property.valueIndex, 1)

        newValue = "Baz"
        property.value = newValue
        XCTAssertEqual(property.value, newValue)
        XCTAssertEqual(property.valueIndex, 0)

        newValue = "404"
        property.value = newValue
        XCTAssertEqual(property.value, "Baz")
        XCTAssertEqual(property.valueIndex, 0)
    }

    // MARK: View Model

    func test_viewModelInstance_viewModelProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.viewModelInstanceProperty(fromPath: "Nested"))
        XCTAssertNil(instance.viewModelInstanceProperty(fromPath: "404"))
    }

    func test_viewModelInstance_viewModelProperty_setsNestedValue() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.viewModelInstanceProperty(fromPath: "Nested")!
        let nestedProperty = property.stringProperty(fromPath: "String")
        XCTAssertNotNil(nestedProperty)
        XCTAssertEqual(nestedProperty!.value, "Nested")

        let newValue = "XCTest"
        nestedProperty!.value = newValue
        XCTAssertEqual(nestedProperty!.value, newValue)
    }

    func test_viewModelInstance_nested_areIdentical() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!

        let fromPath = instance.viewModelInstanceProperty(fromPath: "Nested/DeeperNested")!
        let nested = instance.viewModelInstanceProperty(fromPath: "Nested")!.viewModelInstanceProperty(fromPath: "DeeperNested")!
        XCTAssertIdentical(fromPath, nested)
    }

    func test_viewModelInstance_replace_withCorrectInstance_setsNewInstance() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertEqual(instance.stringProperty(fromPath: "Nested/String")!.value, "Nested")

//         Test one-level deep + caching
        let replacement = file.viewModelNamed("Nested")!.createInstance()!
        replacement.stringProperty(fromPath: "String")!.value = "Hello, Rive"
        var replaced = instance.setViewModelInstanceProperty(fromPath: "Nested", to: replacement)
        XCTAssertTrue(replaced)
        XCTAssertEqual(instance.stringProperty(fromPath: "Nested/String")!.value, "Hello, Rive")
        XCTAssertIdentical(
            instance.viewModelInstanceProperty(fromPath: "Nested"),
            replacement
        )

//         Test two-level deep traversal + caching
        let nestedReplacement = file.viewModelNamed("Default")!.createDefaultInstance()!
        replaced = instance.setViewModelInstanceProperty(fromPath: "Nested/DeeperNested", to: nestedReplacement)
        XCTAssertTrue(replaced)
        XCTAssertIdentical(
            instance.viewModelInstanceProperty(fromPath: "Nested/DeeperNested")!,
            nestedReplacement
        )

        XCTAssertIdentical(
            instance.viewModelInstanceProperty(fromPath: "Nested/DeeperNested")!,
            instance.viewModelInstanceProperty(fromPath: "Nested")!.viewModelInstanceProperty(fromPath: "DeeperNested")
        )

        XCTAssertEqual(
            instance.stringProperty(fromPath: "Nested/DeeperNested/String")!.value,
            instance.viewModelInstanceProperty(fromPath: "Nested")!
                .viewModelInstanceProperty(fromPath: "DeeperNested")!
                .stringProperty(fromPath: "String")!.value
        )
    }

    func test_viewModelInstance_replace_withIncorrectInstance_returnsFalse() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let replacement = file.viewModelNamed("Default")!.createInstance()!
        let replaced = instance.setViewModelInstanceProperty(fromPath: "Nested", to: replacement)
        XCTAssertFalse(replaced)
    }

    // MARK: Trigger

    func test_viewModelInstance_triggerProperty_returnsPropertyOrNil() {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.triggerProperty(fromPath: "Trigger Red"))
        XCTAssertNotNil(instance.triggerProperty(fromPath: "Trigger Green"))
        XCTAssertNotNil(instance.triggerProperty(fromPath: "Trigger Blue"))
        XCTAssertNil(instance.triggerProperty(fromPath: "404"))
    }

    // MARK: Image

    func test_viewModelInstance_imageProperty_returnsPropertyOrNil() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        XCTAssertNotNil(instance.imageProperty(fromPath: "Image"))
        XCTAssertNil(instance.imageProperty(fromPath: "404"))
    }

    func test_viewModelInstance_imageProperty_canSetValue() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let property = instance.imageProperty(fromPath: "Image")!

        let bundle = Bundle(for: type(of: self))
        let fileURL = bundle.url(forResource: "1x1_jpg", withExtension: "jpg")!
        let data = try Data(contentsOf: fileURL)
        let renderImage = RiveRenderImage(data: data)!
        property.setValue(renderImage)
        XCTAssertTrue(property.hasChanged)
        property.clearChanges()
        property.setValue(nil)
        XCTAssertTrue(property.hasChanged)
    }

    // MARK: Lists

    func test_viewModelInstance_listProperty_canAddAndRemoveAndSwapIdenticalItems() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!

        let list = instance.listProperty(fromPath: "List")!

        XCTAssertEqual(list.count, 0)

        let nestedInstance = file.viewModelNamed("Nested")!.createInstance()!
        list.append(nestedInstance)
        XCTAssertEqual(list.count, 1)
        XCTAssertIdentical(list.instance(at: 0), nestedInstance)

        list.remove(nestedInstance)
        XCTAssertEqual(list.count, 0)
        XCTAssertNil(list.instance(at: 0))

        let instance1 = file.viewModelNamed("Nested")!.createInstance()!
        let instance2 = file.viewModelNamed("Nested")!.createInstance()!
        list.append(instance1)
        list.append(instance2)
        XCTAssertEqual(list.count, 2)
        XCTAssertIdentical(list.instance(at: 0), instance1)
        XCTAssertIdentical(list.instance(at: 1), instance2)

        list.remove(at: 0)
        XCTAssertEqual(list.count, 1)
        XCTAssertIdentical(list.instance(at: 0), instance2)

        let instance3 = file.viewModelNamed("Nested")!.createInstance()!
        list.append(instance3)
        list.swap(at: 0, with: 1)
        XCTAssertIdentical(list.instance(at: 0), instance3)
        XCTAssertIdentical(list.instance(at: 1), instance2)

        for i in (0..<list.count).reversed() {
            list.remove(at: Int32(i))
        }

        let instance4 = file.viewModelNamed("Nested")!.createInstance()!
        let instance5 = file.viewModelNamed("Nested")!.createInstance()!
        let instance6 = file.viewModelNamed("Nested")!.createInstance()!
        XCTAssertFalse(list.insert(instance4, at: 100))
        XCTAssertTrue(list.insert(instance4, at: 0))
        XCTAssertTrue(list.insert(instance5, at: 1))
        // One final check to check false if index > list.count (currently 2)
        XCTAssertFalse(list.insert(instance6, at: 3))
        XCTAssertIdentical(list.instance(at: 0)!, instance4)
        XCTAssertEqual(list.count, 2)
    }

    // MARK: Artboard

    func test_viewModelInstance_artboardInstance_canSetValue() throws {
        let file = try RiveFile(testfileName: "data_binding_artboard")

        let viewModel = file.viewModelNamed("Default")!
        let properties = viewModel.properties
        XCTAssertEqual(properties.count, 1)
        XCTAssertEqual(properties.first!.name, "Artboard")
        XCTAssertEqual(properties.first!.type, .artboard)


        let instance = viewModel.createInstance()!

        let artboardProperty = instance.artboardProperty(fromPath: "Artboard")
        XCTAssertNotNil(artboardProperty)
        XCTAssertNil(instance.artboardProperty(fromPath: "404"))

        let red = try file.bindableArtboard(withName: "Red")
        let green = try file.bindableArtboard(withName: "Green")
        let blue = try file.bindableArtboard(withName: "Blue")

        let expectation = expectation(description: "artboard did set value")
        expectation.expectedFulfillmentCount = 4

        artboardProperty?.addListener {
            expectation.fulfill()
        }

        artboardProperty?.setValue(red)
        instance.updateListeners()
        artboardProperty?.clearChanges()

        artboardProperty?.setValue(green)
        instance.updateListeners()
        artboardProperty?.clearChanges()

        artboardProperty?.setValue(blue)
        instance.updateListeners()
        artboardProperty?.clearChanges()

        artboardProperty?.setValue(nil)
        instance.updateListeners()
        artboardProperty?.clearChanges()

        wait(for: [expectation], timeout: 1)
    }

    // MARK: Binding

    func test_binding_artboard_stringProperty_updatesTextRun() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        artboard.bind(viewModelInstance: instance)
        instance.stringProperty(fromPath: "String")?.value = "Test"
        artboard.advance(by: 0)
        XCTAssertEqual(artboard.textRun("String")?.text(), "Test")
    }

    func test_binding_stateMachine_booleanProperty_updatesState() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        instance.booleanProperty(fromPath: "Boolean")?.value = true
        stateMachine.advance(by: 0)
    }

    // MARK: - AutoBind

    func test_riveModel_autoBind_artboard() throws {
        let model = RiveModel(riveFile: file)

        let expectation = expectation(description: "autoBind callback")
        expectation.expectedFulfillmentCount = 3
        var instance: RiveDataBindingViewModel.Instance?

        model.enableAutoBind { i in
            instance = i
            expectation.fulfill()
        }

        try model.setArtboard("Artboard")
        try model.setArtboard()
        try model.setArtboard(0)
        wait(for: [expectation], timeout: 1)

        let stringProperty = instance?.stringProperty(fromPath: "String")
        stringProperty?.value = "Test"
        let textRun = model.artboard?.textRun("String")
        model.artboard.advance(by: 0)
        XCTAssertEqual(textRun?.text(), "Test")
    }

    func test_riveModel_autoBind_stateMachine() throws {
        let model = RiveModel(riveFile: file)

        let expectation = expectation(description: "autoBind callback")
        expectation.expectedFulfillmentCount = 3
        var instance: RiveDataBindingViewModel.Instance?

        model.enableAutoBind { i in
            instance = i
            expectation.fulfill()
        }

        try model.setArtboard()
        try model.setStateMachine("State Machine 1")
        try model.setStateMachine()
        wait(for: [expectation], timeout: 1)

        let booleanProperty = instance?.booleanProperty(fromPath: "Boolean")
        booleanProperty?.value = true
        model.stateMachine?.advance(by: 0)
        XCTAssertNotNil(model.stateMachine?.stateChanges().firstIndex(of: "boolean_on"))
    }

    func test_riveModel_autoBind_withArtboardAlreadySet() throws {
        let model = RiveModel(riveFile: file)
        try model.setArtboard()

        let expectation = expectation(description: "autoBind callback")
        model.enableAutoBind { _ in
            expectation.fulfill()
        }

        wait(for: [expectation])
    }

    func test_riveModel_autoBind_enableThenDisable() throws {
        let model = RiveModel(riveFile: file)

        let expectation = expectation(description: "autoBind callback")
        expectation.isInverted = true
        model.enableAutoBind { _ in
            expectation.fulfill()
        }
        model.disableAutoBind()
        try model.setArtboard()

        wait(for: [expectation], timeout: 0.1)
    }

    func test_riveViewModel_riveModel_autoBind_fromDefaults() throws {
        let model = RiveModel(riveFile: file)
        let expectation = expectation(description: "autoBind callback")
        expectation.expectedFulfillmentCount = 2
        model.enableAutoBind { _ in
            expectation.fulfill()
        }

        _ = RiveViewModel(model)
        wait(for: [expectation], timeout: 1)
    }

    // MARK: - Property Listeners

    func test_stringProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let string = instance.stringProperty(fromPath: "String")!
        let listener = string.addListener { value in
            XCTAssertEqual(value, "Foo")
            addExpectation.fulfill()
        }

        string.value = "Foo"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        string.removeListener(listener)
        string.value = "Bar"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_numberProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let number = instance.numberProperty(fromPath: "Number")!
        let listener = number.addListener { value in
            XCTAssertEqual(value, 1)
            addExpectation.fulfill()
        }

        // Prematurely advance by 0 because there's a quirk in the test
        // state machine that on first advance sets the number to 0
        stateMachine.advance(by: 0)
        number.value = 1
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        number.removeListener(listener)
        number.value = 2
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_booleanProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let boolean = instance.booleanProperty(fromPath: "Boolean")!
        let listener = boolean.addListener { value in
            XCTAssertEqual(value, true)
            addExpectation.fulfill()
        }

        boolean.value = true
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        boolean.removeListener(listener)
        boolean.value = false
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_colorProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let color = instance.colorProperty(fromPath: "Color")!
        let listener = color.addListener { value in
            XCTAssertEqual(Color(value), Color(.red))
            addExpectation.fulfill()
        }

        color.value = .red
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        color.removeListener(listener)
        color.value = .white
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_enumProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let property = instance.enumProperty(fromPath: "Enum")!
        let last = property.values.first!
        let listener = property.addListener { value in
            XCTAssertEqual(value, last)
            addExpectation.fulfill()
        }

        property.value = last
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        property.removeListener(listener)
        property.value = property.values.first!
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_triggerProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let property = instance.triggerProperty(fromPath: "Trigger Red")!
        let listener = property.addListener {
            addExpectation.fulfill()
        }

        property.trigger()
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        property.removeListener(listener)
        property.trigger()
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_listProperty_listener_canAddAndRemoveListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let addExpectation = expectation(description: "listener called")

        let property = instance.listProperty(fromPath: "List")!
        let listener = property.addListener {
            addExpectation.fulfill()
        }

        let testInstance = file.viewModelNamed("Test")!.createInstance()!
        property.append(testInstance)
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [addExpectation], timeout: 1)

        let removeExpectation = expectation(description: "listener called")
        removeExpectation.isInverted = true
        property.removeListener(listener)
        property.remove(testInstance)
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [removeExpectation], timeout: 1)
    }

    func test_listProperty_whenListItemUpdates_callsListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let testExpectation = expectation(description: "listener called")
        testExpectation.expectedFulfillmentCount = 2

        let testInstance = file.viewModelNamed("Test")!.createInstance()!
        let testTrigger = testInstance.triggerProperty(fromPath: "Trigger Red")!
        testTrigger.addListener {
            testExpectation.fulfill()
        }

        let list = instance.listProperty(fromPath: "List")!
        list.append(testInstance)

        testTrigger.trigger()
        stateMachine.advance(by: 0)
        instance.updateListeners()

        let nestedList = testInstance.listProperty(fromPath: "List")!
        let nestedInstance = file.viewModelNamed("Test")!.createInstance()!
        nestedList.append(nestedInstance)
        let nestedTrigger = nestedInstance.triggerProperty(fromPath: "Trigger Red")!
        nestedTrigger.addListener {
            testExpectation.fulfill()
        }
        nestedTrigger.trigger()
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [testExpectation], timeout: 1)
    }

    func test_property_listener_afterAddingSingleListener_callsListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        string.addListener { _ in
            expectation.fulfill()
        }

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_property_listener_afterAddingAndRemovingSingleListener_doesNotCallListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.isInverted = true
        let uuid = string.addListener { _ in
            expectation.fulfill()
        }
        string.removeListener(uuid)

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_property_listener_afterAddingMultipleListeners_callsListeners() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.expectedFulfillmentCount = 2
        string.addListener { _ in
            expectation.fulfill()
        }
        string.addListener { _ in
            expectation.fulfill()
        }

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_property_listener_afterAddingAndRemovingMultipleListeners_callsListeners() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.isInverted = true
        let uuid1 = string.addListener { _ in
            expectation.fulfill()
        }
        let uuid2 = string.addListener { _ in
            expectation.fulfill()
        }
        string.removeListener(uuid1)
        string.removeListener(uuid2)

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_property_listener_afterAddingTwoAndRemovingOneListener_callsListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.expectedFulfillmentCount = 1
        let uuid1 = string.addListener { _ in
            expectation.fulfill()
        }
        string.addListener { _ in
            expectation.fulfill()
        }
        string.removeListener(uuid1)

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_property_listener_addingTwo_updatingOne_callsOneListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.expectedFulfillmentCount = 1
        string.addListener { _ in
            expectation.fulfill()
        }
        let number = instance.numberProperty(fromPath: "Number")!
        number.addListener { _ in
            expectation.fulfill()
        }

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_property_listener_addingTwo_callsBoth_thenResets() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.expectedFulfillmentCount = 2

        string.addListener { _ in
            XCTAssertTrue(string.hasChanged)
            expectation.fulfill()
        }

        string.addListener { _ in
            XCTAssertTrue(string.hasChanged)
            expectation.fulfill()
        }

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)

        XCTAssertFalse(string.hasChanged)
    }

    func test_view_listener_afterAddingOneListener_onAdvance_callsListener() throws {
        let model = RiveModel(riveFile: file)
        let viewModel = RiveViewModel(model, autoPlay: false)

        let instance = model.riveFile.viewModelNamed("Test")!.createDefaultInstance()!
        viewModel.riveModel!.stateMachine!.bind(viewModelInstance: instance)

        let string = instance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        string.addListener { _ in
            expectation.fulfill()
        }
        instance.numberProperty(fromPath: "Number")!.addListener { _ in
            // This should not be called, since number was never updated
            expectation.fulfill()
        }

        let view = viewModel.createRiveView()

        string.value = "Listener"
        view.play() // == .advance(delta: 0)
        wait(for: [expectation], timeout: 1)
    }

    func test_nestedViewModel_property_withReference_afterAddingSingleListener_callsListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let nestedInstance = instance.viewModelInstanceProperty(fromPath: "Nested")!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = nestedInstance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        string.addListener { _ in
            expectation.fulfill()
        }

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_nestedViewModel_property_withNoReference_afterAddingSingleListener_callsListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        let string = instance.viewModelInstanceProperty(fromPath: "Nested")!.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        string.addListener { _ in
            expectation.fulfill()
        }

        string.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_nestedViewModel_property_withReference_afterAddingSingleListener_withoutRemove_onDeinit_doesNotCallListener() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        let nestedInstance = instance.viewModelInstanceProperty(fromPath: "Nested")!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)
        var string: RiveDataBindingViewModel.Instance.StringProperty? = nestedInstance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        expectation.isInverted = true
        string?.addListener { _ in
            expectation.fulfill()
        }
        string = nil

        string?.value = "Listener"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_nestedViewModel_property_afterAddingSingleListener_whenBound_andParentReleased_callsListener() throws {
        var instance: RiveDataBindingViewModel.Instance? = file.viewModelNamed("Test")!.createDefaultInstance()!
        let nestedInstance = instance!.viewModelInstanceProperty(fromPath: "Nested")!
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance!)
        let string = nestedInstance.stringProperty(fromPath: "String")!

        let expectation = expectation(description: "Updated")
        string.addListener { _ in
            expectation.fulfill()
        }

        instance = nil
        stateMachine.bind(viewModelInstance: nestedInstance)

        string.value = "Listener"
        stateMachine.advance(by: 0)
        nestedInstance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }

    func test_instance_property_whenBoundToTwoStateMachines_ifNoAdditionalChanges_callsListenerOnce() throws {
        let instance = file.viewModelNamed("Test")!.createDefaultInstance()!
        
        let artboard = try file.artboard()
        let stateMachine = try artboard.stateMachine(from: 0)
        stateMachine.bind(viewModelInstance: instance)

        let file2 = try! RiveFile(testfileName: "data_binding_test")
        let artboard2 = try file2.artboard()
        let stateMachine2 = try artboard2.stateMachine(from: 0)
        stateMachine2.bind(viewModelInstance: instance)

        let expectation = expectation(description: "listener called")
        expectation.expectedFulfillmentCount = 1

        let string = instance.stringProperty(fromPath: "String")!
        string.addListener { value in
            XCTAssertEqual(value, "Foo")
            expectation.fulfill()
        }

        string.value = "Foo"
        stateMachine.advance(by: 0)
        instance.updateListeners()

        stateMachine2.advance(by: 0)
        instance.updateListeners()

        wait(for: [expectation], timeout: 1)
    }
}

// MARK: - Helpers

fileprivate struct Color: Equatable {
    let a: Int
    let r: Int
    let g: Int
    let b: Int

    init(_ color: UIColor) {
        var a: CGFloat = 0
        var r: CGFloat = 0
        var g: CGFloat = 0
        var b: CGFloat = 0
        color.getRed(&r, green: &g, blue: &b, alpha: &a)
        self.a = Int(a * 255)
        self.r = Int(r * 255)
        self.g = Int(g * 255)
        self.b = Int(b * 255)
    }
}
