<?php

namespace Drupal\Tests\search_api\Unit\Processor;

use Drupal\Core\Form\FormState;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\TypedData\ComplexDataInterface;
use Drupal\Core\TypedData\DataDefinition;
use Drupal\Core\TypedData\TypedDataInterface;
use Drupal\search_api\Datasource\DatasourceInterface;
use Drupal\search_api\DataType\DataTypeInterface;
use Drupal\search_api\Entity\Index;
use Drupal\search_api\IndexInterface;
use Drupal\search_api\Item\Field;
use Drupal\search_api\Item\FieldInterface;
use Drupal\search_api\Item\ItemInterface;
use Drupal\search_api\Plugin\search_api\processor\AggregatedFields;
use Drupal\search_api\Plugin\search_api\processor\Property\AggregatedFieldProperty;
use Drupal\search_api\Processor\ProcessorInterface;
use Drupal\search_api\Processor\ProcessorProperty;
use Drupal\search_api\Utility\PluginHelperInterface;
use Drupal\search_api\Utility\Utility;
use Drupal\Tests\UnitTestCase;

/**
 * Tests the "Aggregated fields" processor.
 *
 * @group search_api
 *
 * @see \Drupal\search_api\Plugin\search_api\processor\AggregatedFields
 */
class AggregatedFieldsTest extends UnitTestCase {

  use TestItemsTrait;

  /**
   * The processor to be tested.
   *
   * @var \Drupal\search_api\Plugin\search_api\processor\AggregatedFields
   */
  protected $processor;

  /**
   * A search index mock for the tests.
   *
   * @var \Drupal\search_api\IndexInterface|\PHPUnit\Framework\MockObject\MockObject
   */
  protected $index;

  /**
   * The field ID used in this test.
   *
   * @var string
   */
  protected $fieldId = 'aggregated_field';

  /**
   * The callback with which text values should be preprocessed.
   *
   * @var callable
   */
  protected $valueCallback;

  /**
   * Creates a new processor object for use in the tests.
   */
  protected function setUp(): void {
    parent::setUp();

    $datasource = $this->createMock(DatasourceInterface::class);
    $datasource->expects($this->any())
      ->method('getPropertyDefinitions')
      ->willReturn([]);
    $this->index = new Index([
      'datasourceInstances' => [
        'entity:test1' => $datasource,
        'entity:test2' => $datasource,
        'entity:test3' => $datasource,
      ],
      'processorInstances' => [],
      'field_settings' => [
        'foo' => [
          'type' => 'string',
          'datasource_id' => 'entity:test1',
          'property_path' => 'foo',
        ],
        'bar' => [
          'type' => 'string',
          'datasource_id' => 'entity:test1',
          'property_path' => 'foo:bar',
        ],
        'bla' => [
          'type' => 'string',
          'datasource_id' => 'entity:test2',
          'property_path' => 'foobaz:bla',
        ],
        'always_empty' => [
          'type' => 'string',
          'datasource_id' => 'entity:test3',
          'property_path' => 'always_empty',
        ],
        'aggregated_field' => [
          'type' => 'text',
          'property_path' => 'aggregated_field',
        ],
      ],
    ], 'search_api_index');
    $this->processor = new AggregatedFields(['#index' => $this->index], 'aggregated_field', []);
    $this->index->addProcessor($this->processor);
    $this->setUpMockContainer();

    $plugin_helper = $this->createMock(PluginHelperInterface::class);
    $this->container->set('search_api.plugin_helper', $plugin_helper);

    // We want to check correct data type handling, so we need a somewhat more
    // complex mock-up for the datatype plugin handler.
    /** @var \PHPUnit\Framework\MockObject\MockObject|\Drupal\search_api\DataType\DataTypePluginManager $data_type_manager */
    $data_type_manager = $this->container->get('plugin.manager.search_api.data_type');
    $data_type_manager->method('hasDefinition')
      ->willReturn(TRUE);
    $this->valueCallback = function ($value) {
      if (is_numeric($value)) {
        return $value + 1;
      }
      else {
        return '*' . $value;
      }
    };
    $data_type = $this->createMock(DataTypeInterface::class);
    $data_type->method('getValue')
      ->willReturnCallback($this->valueCallback);
    $data_type_manager->method('createInstance')
      ->willReturnMap([
        ['text', [], $data_type],
      ]);
  }

  /**
   * Tests aggregated fields of the given type.
   *
   * @param string $type
   *   The aggregation type to test.
   * @param array $expected
   *   The expected values for the two items.
   * @param bool $integer
   *   (optional) TRUE if the items' normal fields should contain integers,
   *   FALSE otherwise.
   * @param array $additional_config
   *   (optional) Additional configuration to set on the field.
   *
   * @dataProvider aggregationTestsDataProvider
   */
  public function testAggregation(string $type, array $expected, bool $integer = FALSE, array $additional_config = []): void {
    // Add the field configuration.
    $configuration = [
      'type' => $type,
      'fields' => [
        'entity:test1/foo',
        'entity:test1/foo:bar',
        'entity:test2/foobaz:bla',
        'entity:test3/always_empty',
      ],
    ];
    $configuration += $additional_config;
    $this->index->getField($this->fieldId)->setConfiguration($configuration);

    if ($integer) {
      $field_values = [
        'foo' => [2, 4],
        'bar' => [16],
        'bla' => [7],
      ];
    }
    else {
      $field_values = [
        'foo' => ['foo', 'bar'],
        'bar' => ['baz'],
        'bla' => ['foobar'],
      ];
    }
    $items = [];
    $i = 0;
    foreach (['entity:test1', 'entity:test2', 'entity:test3'] as $datasource_id) {
      $this->itemIds[$i++] = $item_id = Utility::createCombinedId($datasource_id, '1:en');
      $item = \Drupal::getContainer()
        ->get('search_api.fields_helper')
        ->createItem($this->index, $item_id);
      foreach ([NULL, $datasource_id] as $field_datasource_id) {
        foreach ($this->index->getFieldsByDatasource($field_datasource_id) as $field_id => $field) {
          $field = clone $field;
          if (!empty($field_values[$field_id])) {
            $field->setValues($field_values[$field_id]);
          }
          $item->setField($field_id, $field);
        }
      }
      $item->setFieldsExtracted(TRUE);
      $items[$item_id] = $item;
    }

    // Add the processor's field values to the items.
    foreach ($items as $item) {
      $this->processor->addFieldValues($item);
    }

    $this->assertEquals(array_map($this->valueCallback, $expected[0]), $items[$this->itemIds[0]]->getField($this->fieldId)->getValues(), 'Correct aggregation for item 1.');
    $this->assertEquals(array_map($this->valueCallback, $expected[1]), $items[$this->itemIds[1]]->getField($this->fieldId)->getValues(), 'Correct aggregation for item 2.');
    $this->assertEquals(array_map($this->valueCallback, $expected[2]), $items[$this->itemIds[2]]->getField($this->fieldId)->getValues(), 'Correct aggregation for item 3.');
  }

  /**
   * Provides test data for aggregation tests.
   *
   * @return array
   *   An array containing test data sets, with each being an array of
   *   arguments to pass to the test method.
   *
   * @see static::testAggregation()
   */
  public function aggregationTestsDataProvider() {
    return [
      '"Union" aggregation' => [
        'union',
        [
          ['foo', 'bar', 'baz'],
          ['foobar'],
          [],
        ],
      ],
      '"Concatenation" aggregation' => [
        'concat',
        [
          ["foo\n\nbar\n\nbaz"],
          ['foobar'],
          [''],
        ],
      ],
      '"Concatenation" aggregation with different separator' => [
        'concat',
        [
          ['foo bar baz'],
          ['foobar'],
          [''],
        ],
        FALSE,
        [
          'separator' => ' ',
        ],
      ],
      '"Sum" aggregation' => [
        'sum',
        [
          [22],
          [7],
          [0],
        ],
        TRUE,
      ],
      '"Count" aggregation' => [
        'count',
        [
          [3],
          [1],
          [0],
        ],
      ],
      '"Maximum" aggregation' => [
        'max',
        [
          [16],
          [7],
          [],
        ],
        TRUE,
      ],
      '"Minimum" aggregation' => [
        'min',
        [
          [2],
          [7],
          [],
        ],
        TRUE,
      ],
      '"First" aggregation' => [
        'first',
        [
          ['foo'],
          ['foobar'],
          [],
        ],
      ],
      '"Last" aggregation' => [
        'last',
        [
          ['baz'],
          ['foobar'],
          [],
        ],
      ],
      '"First letter" aggregation' => [
        'first_char',
        [
          ['F'],
          ['F'],
          [],
        ],
      ],
    ];
  }

  /**
   * Tests whether the properties are correctly altered.
   *
   * @see \Drupal\search_api\Plugin\search_api\processor\AggregatedFields::getPropertyDefinitions()
   */
  public function testGetPropertyDefinitions() {
    /** @var \Drupal\Core\StringTranslation\TranslationInterface $translation */
    $translation = $this->getStringTranslationStub();
    $this->processor->setStringTranslation($translation);

    // Check for added properties when no datasource is given.
    /** @var \Drupal\search_api\Processor\ProcessorPropertyInterface[] $properties */
    $properties = $this->processor->getPropertyDefinitions(NULL);

    $this->assertArrayHasKey('aggregated_field', $properties, 'The "aggregated_field" property was added to the properties.');
    $property = $properties['aggregated_field'];
    $this->assertInstanceOf(AggregatedFieldProperty::class, $property, 'The "aggregated_field" property has the correct class.');
    $this->assertEquals('string', $property->getDataType(), 'Correct data type set in the data definition.');
    $this->assertEquals($translation->translate('Aggregated field'), $property->getLabel(), 'Correct label set in the data definition.');
    $expected_description = $translation->translate('An aggregation of multiple other fields.');
    $this->assertEquals($expected_description, $property->getDescription(), 'Correct description set in the data definition.');
    $this->assertTrue($properties['aggregated_field']->isList());

    // Verify that the property configuration form works correctly.
    $property->setStringTranslation($this->createMock(TranslationInterface::class));
    $field = $this->createMock(FieldInterface::class);
    $field->method('getIndex')->willReturn($this->processor->getIndex());
    $field->method('getConfiguration')->willReturn([
      'type' => 'concat',
      'separator' => "\n\t \\XX",
      'fields' => [],
    ]);
    $form = $property->buildConfigurationForm($field, [], new FormState());
    $this->assertEquals("\\n\\t \\\\XX", $form['separator']['#default_value']);

    // Verify that there are no properties if a datasource is given.
    $datasource = $this->createMock(DatasourceInterface::class);
    $properties = $this->processor->getPropertyDefinitions($datasource);
    $this->assertEmpty($properties, 'Datasource-specific properties did not get changed.');
  }

  /**
   * Makes sure that the property's isList() method works correctly.
   *
   * The property should report itself as a list only if its type is either set
   * to "union" or is unknown.
   *
   * @param string $type
   *   The aggregation type configured for the field.
   * @param bool $expect_list
   *   The expected return value of isList().
   *
   * @covers \Drupal\search_api\Plugin\search_api\processor\Property\AggregatedFieldProperty::isList
   * @dataProvider propertyIsListTestDataProvider
   */
  public function testPropertyIsList(string $type, bool $expect_list): void {
    $this->processor->setStringTranslation($this->getStringTranslationStub());

    $index = $this->createMock(IndexInterface::class);
    $index->method('getPropertyDefinitions')->willReturnMap([
      [NULL, $this->processor->getPropertyDefinitions(NULL)],
    ]);

    $field = (new Field($index, 'aggregated'))
      ->setDatasourceId(NULL)
      ->setPropertyPath('aggregated_field')
      ->setConfiguration([
        'type' => $type,
      ]);
    $this->assertEquals($expect_list, $field->getDataDefinition()->isList());
  }

  /**
   * Provides test data for testPropertyIsList().
   *
   * @return array
   *   An array containing test data sets, with each being an array of arguments
   *   to pass to the test method.
   *
   * @see static::testPropertyIsList()
   */
  public function propertyIsListTestDataProvider(): array {
    return [
      ['union', TRUE],
      ['sum', FALSE],
      ['concat', FALSE],
    ];
  }

  /**
   * Tests that field extraction in the processor works correctly.
   */
  public function testFieldExtraction() {
    /** @var \Drupal\Core\TypedData\ComplexDataInterface|\PHPUnit\Framework\MockObject\MockObject $object */
    $object = $this->createMock(ComplexDataInterface::class);
    $bar_foo_property = $this->createMock(TypedDataInterface::class);
    $bar_foo_property->method('getValue')
      ->willReturn('value3');
    $bar_foo_property->method('getDataDefinition')
      ->willReturn(new DataDefinition());
    $bar_property = $this->createMock(ComplexDataInterface::class);
    $bar_property->method('get')
      ->willReturnMap([
        ['foo', $bar_foo_property],
      ]);
    $bar_property->method('getProperties')
      ->willReturn([
        'foo' => TRUE,
      ]);
    $foobar_property = $this->createMock(TypedDataInterface::class);
    $foobar_property->method('getValue')
      ->willReturn('wrong_value2');
    $foobar_property->method('getDataDefinition')
      ->willReturn(new DataDefinition());
    $object->method('get')
      ->willReturnMap([
        ['bar', $bar_property],
        ['foobar', $foobar_property],
      ]);
    $object->method('getProperties')
      ->willReturn([
        'bar' => TRUE,
        'foobar' => TRUE,
      ]);

    /** @var \Drupal\search_api\IndexInterface|\PHPUnit\Framework\MockObject\MockObject $index */
    $index = $this->createMock(IndexInterface::class);

    $fields_helper = \Drupal::getContainer()->get('search_api.fields_helper');
    $field = $fields_helper->createField($index, 'aggregated_field', [
      'property_path' => 'aggregated_field',
      'configuration' => [
        'type' => 'union',
        'fields' => [
          'aggregated_field',
          'foo',
          'entity:test1/bar:foo',
          'entity:test1/baz',
          'entity:test2/foobar',
        ],
      ],
    ]);
    $index->method('getFields')->willReturn([
      'aggregated_field' => $field,
    ]);
    $index->method('getPropertyDefinitions')
      ->willReturnMap([
        [
          NULL,
          [
            'foo' => new ProcessorProperty([
              'processor_id' => 'processor1',
            ]),
          ],
        ],
        [
          'entity:test1',
          [
            'bar' => new DataDefinition(),
            'foobar' => new DataDefinition(),
          ],
        ],
      ]);
    $processor_mock = $this->createMock(ProcessorInterface::class);
    $processor_mock->method('addFieldValues')
      ->willReturnCallback(function (ItemInterface $item) {
        foreach ($item->getFields(FALSE) as $field) {
          if ($field->getCombinedPropertyPath() == 'foo') {
            $field->setValues(['value4', 'value5']);
          }
        }
      });
    $index->method('getProcessorsByStage')
      ->willReturnMap([
        [
          ProcessorInterface::STAGE_ADD_PROPERTIES,
          [],
          [
            'aggregated_field' => $this->processor,
            'processor1' => $processor_mock,
          ],
        ],
      ]);
    $this->processor->setIndex($index);

    /** @var \Drupal\search_api\Datasource\DatasourceInterface|\PHPUnit\Framework\MockObject\MockObject $datasource */
    $datasource = $this->createMock(DatasourceInterface::class);
    $datasource->method('getPluginId')
      ->willReturn('entity:test1');

    $item = $fields_helper->createItem($index, 'id', $datasource);
    $item->setOriginalObject($object);
    $item->setField('aggregated_field', clone $field);
    $item->setField('test1', $fields_helper->createField($index, 'test1', [
      'property_path' => 'baz',
      'values' => [
        'wrong_value1',
      ],
    ]));
    $item->setField('test2', $fields_helper->createField($index, 'test2', [
      'datasource_id' => 'entity:test1',
      'property_path' => 'baz',
      'values' => [
        'value1',
        'value2',
      ],
    ]));
    $item->setFieldsExtracted(TRUE);

    $this->processor->addFieldValues($item);

    $expected = [
      'value1',
      'value2',
      'value3',
      'value4',
      'value5',
    ];
    $actual = $item->getField('aggregated_field')->getValues();
    sort($actual);
    $this->assertEquals($expected, $actual);
  }

}
