<?php

declare(strict_types=1);

namespace Drupal\Tests\simple_interactive_maps\FunctionalJavascript;

use Behat\Mink\Element\NodeElement;
use Drupal\Core\Session\AccountInterface;
use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
use Drupal\simple_interactive_maps\Entity\InteractiveMap;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;

/**
 * Verifies the client-side region behaviors actually run in a real browser.
 *
 * Covers js/simple_interactive_maps.js and the js/map.action.*.js files:
 * each region gets the accessibility role its action plugin sets, hovering
 * shows an Opentip tooltip, and clicking a navigate_action region navigates.
 *
 * WebDriverTestBase fails the test automatically on any JS console error
 * (failOnJavascriptConsoleErrors defaults to TRUE), so this also acts as a
 * general regression guard against front-end errors when the map renders.
 */
#[Group('simple_interactive_maps')]
#[RunTestsInSeparateProcesses]
final class MapInteractionTest extends WebDriverTestBase {

  /**
   * {@inheritdoc}
   */
  protected $defaultTheme = 'stark';

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'block',
    'file',
    'filter',
    'field_group',
    'simple_interactive_maps',
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    // simple_interactive_maps.ajax_map_loader requires 'access content',
    // which anonymous users don't have by default in a fresh test install.
    user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, ['access content']);

    $regions = $this->buildDefaultRegions();

    // Give a handful of specific regions distinct actions/tooltips to
    // exercise the JS behaviors.
    $regions['AL']['action'] = [
      'plugin_id' => 'navigate_action',
      'plugin_configuration' => ['url' => '/user/login', 'new_tab' => FALSE],
    ];
    $regions['AK']['action'] = [
      'plugin_id' => 'modal_content',
      'plugin_configuration' => ['modal_content' => ['value' => 'Alaska modal body text', 'format' => 'plain_text']],
    ];
    $regions['AZ']['tooltip'] = ['value' => 'Arizona tooltip text', 'format' => 'plain_text'];
    $regions['CA']['action'] = [
      'plugin_id' => 'ajax_load_map',
      'plugin_configuration' => ['target_map_id' => 'test_map_2'],
    ];

    InteractiveMap::create([
      'id' => 'test_map',
      'label' => 'Test Map',
      'base_map' => 'us_states_territories',
      'regions' => $regions,
    ])->save();

    $secondMapRegions = $this->buildDefaultRegions();
    $secondMapRegions['AK']['action'] = [
      'plugin_id' => 'modal_content',
      'plugin_configuration' => ['modal_content' => ['value' => 'Test map 2 modal body text', 'format' => 'plain_text']],
    ];
    InteractiveMap::create([
      'id' => 'test_map_2',
      'label' => 'Test Map 2',
      'base_map' => 'us_states_territories',
      'regions' => $secondMapRegions,
    ])->save();

    $this->drupalPlaceBlock('simple_interactive_maps_interactive_map', [
      'map' => 'test_map',
      'show_description' => FALSE,
    ]);
  }

  /**
   * Seeds every region the us_states_territories plugin provides.
   *
   * Mirrors the way InteractiveMapForm::save() does, so MapBuilder does not
   * hit unconfigured region keys.
   */
  private function buildDefaultRegions(): array {
    /** @var \Drupal\simple_interactive_maps\MapDefinitionPluginManager $pluginManager */
    $pluginManager = \Drupal::service('plugin.manager.map_definition');
    $plugin = $pluginManager->createInstance('us_states_territories');

    $regions = [];
    foreach ($plugin->mapData() as $key => $value) {
      $regions[$key] = [
        'id' => $key,
        'label' => $value['label'],
        'hidden' => 0,
        'group' => '',
        'fill_color' => '#4ba0a6',
        'hover_color' => '#076369',
        'stroke_color' => '#e6e6e6',
        'text_color' => '#000000',
        'tooltip' => ['value' => '', 'format' => 'plain_text'],
        'action' => ['plugin_id' => 'none', 'plugin_configuration' => []],
      ];
    }

    return $regions;
  }

  /**
   * Tests that regions get roles matching their configured action.
   */
  public function testRegionsGetRolesMatchingTheirConfiguredAction(): void {
    $this->drupalGet('<front>');
    $assert = $this->assertSession();

    $assert->waitForElement('css', '[data-region-id="AL"]');

    $this->assertSame('link', $this->getRegion('AL')->getAttribute('role'));
    $this->assertSame('button', $this->getRegion('AK')->getAttribute('role'));

    // simple_interactive_maps.js only calls a region's action handler
    // "if (config.action.name !== 'none')", so Drupal.simple_interactive_maps
    // .none() (which would set role="presentation") is never actually
    // invoked for 'none' actions. This asserts the real current behavior,
    // not the seemingly-intended one - a regression here would mean that
    // guard clause changed, which changes the console-error-free
    // ('none' action does nothing) behavior of every unconfigured region.
    $this->assertNull($this->getRegion('AR')->getAttribute('role'));
  }

  /**
   * Tests that hovering a region shows an Opentip tooltip.
   */
  public function testHoveringRegionShowsAnOpentipTooltip(): void {
    $this->drupalGet('<front>');
    $this->assertSession()->waitForElement('css', '[data-region-id="AZ"]');

    $this->getRegion('AZ')->mouseOver();

    $tooltip = $this->assertSession()->waitForElementVisible('css', '.opentip-container');
    $this->assertNotNull($tooltip);
    $this->assertStringContainsString('Arizona tooltip text', $tooltip->getText());
  }

  /**
   * Tests that clicking a navigate_action region navigates.
   */
  public function testClickingNavigateActionRegionNavigates(): void {
    $this->drupalGet('<front>');
    $this->assertSession()->waitForElement('css', '[data-region-id="AL"]');

    $this->getRegion('AL')->click();

    $this->assertSession()->waitForElement('css', 'form.user-login-form');
    $this->assertStringContainsString('/user/login', $this->getSession()->getCurrentUrl());
  }

  /**
   * Tests that clicking a modal_content region opens a dialog.
   */
  public function testClickingModalContentRegionOpensDialog(): void {
    $this->drupalGet('<front>');
    $this->assertSession()->waitForElement('css', '[data-region-id="AK"]');

    // Dispatch the click via JS rather than a native WebDriver click: the
    // Alaska region's SVG path is a small inset box that WebDriver's
    // native element-center click can miss or land on a neighboring
    // element, whereas dispatching the DOM event directly exercises the
    // exact listener registered by map.action.modal-content.js.
    $this->getSession()->executeScript(
      "document.querySelector('[data-region-id=\"AK\"]').dispatchEvent(new MouseEvent('click', {bubbles: true}));"
    );

    $dialog = $this->assertSession()->waitForElement('css', '.interactive-map-dialog');
    $this->assertNotNull($dialog);
    $this->assertStringContainsString('Alaska modal body text', $dialog->getText());
  }

  /**
   * Regression guard for AjaxMapLoaderController's render context crash.
   *
   * It used to call $this->renderer->render() - a call that requires an
   * active render context. Controllers that return a Response object
   * directly (as this one does, an AjaxResponse) never get wrapped in one by
   * Drupal's kernel, so every click on an ajax_load_map region fatally
   * errored in production. Fixed by switching to renderInIsolation().
   */
  public function testClickingAnAjaxLoadMapRegionSwapsTheMap(): void {
    $this->drupalGet('<front>');
    $this->assertSession()->waitForElement('css', '[data-region-id="CA"]');

    $this->assertSame('link', $this->getRegion('CA')->getAttribute('role'));

    // Dispatch via JS rather than a native WebDriver click: California's
    // SVG path is a coastal inset shape that WebDriver's native
    // element-center click can miss or land on a neighboring element.
    $this->getSession()->executeScript(
      "document.querySelector('[data-region-id=\"CA\"]').dispatchEvent(new MouseEvent('click', {bubbles: true}));"
    );

    // Poll for the actual outcome we care about - the wrapper swapping to
    // test_map_2 - rather than Drupal's assertWaitOnAjaxRequest(), which
    // requires zero XHR/animation activity anywhere on the page (via
    // window.drupalActiveXhrCount, jQuery.active, and every Drupal.ajax
    // instance simultaneously). That broader condition is fragile to any
    // unrelated async activity on the page and was observed to time out on
    // drupal.org's CI runners even when the swap itself had completed. A
    // generous timeout accommodates the real server render this round trip
    // requires, which can take noticeably longer on a loaded CI runner than
    // in local development.
    $this->getSession()->wait(30000, "document.querySelector('.interactive_map__wrapper')?.getAttribute('data-map-id') === 'test_map_2'");

    $wrapper = $this->getSession()->getPage()->find('css', '.interactive_map__wrapper');
    $this->assertNotNull($wrapper);
    $this->assertSame('test_map_2', $wrapper->getAttribute('data-map-id'));
    $this->assertSession()->elementExists('css', '.interactive_map__back-link');

    // Regression guard for AjaxMapLoaderController discarding #attached:
    // renderInIsolation() populates the map's library/drupalSettings on the
    // local $build array but never bubbles them anywhere by itself, so
    // unless AjaxMapLoaderController explicitly copies $build['#attached']
    // onto the AjaxResponse, the newly-swapped-in map's region JS behaviors
    // (tooltips, modal/navigate/ajax_load_map click handlers) never get
    // attached and silently do nothing. test_map_2's AK region has a
    // modal_content action configured specifically to prove the behavior
    // actually runs after the swap, not just that the markup swapped.
    $this->assertSame('button', $this->getRegion('AK')->getAttribute('role'));

    $this->getSession()->executeScript(
      "document.querySelector('[data-region-id=\"AK\"]').dispatchEvent(new MouseEvent('click', {bubbles: true}));"
    );

    $dialog = $this->assertSession()->waitForElement('css', '.interactive-map-dialog');
    $this->assertNotNull($dialog);
    $this->assertStringContainsString('Test map 2 modal body text', $dialog->getText());
  }

  /**
   * Regression guard for the AJAX endpoint respecting Drupal's base path.
   *
   * The load-map action JS used to hardcode '/simple-interactive-maps/ajax'.
   * On a site installed in a subdirectory that resolves against the domain
   * root and 404s, so the map swap silently never happens - which is exactly
   * how drupal.org's GitLab CI serves the test site (SIMPLETEST_BASE_URL is
   * http://localhost/$_WEB_ROOT). Local development at a domain root cannot
   * tell a hardcoded path apart from a base-path-aware one, so this
   * simulates a subdirectory install and asserts the URL is built from it.
   */
  public function testAjaxLoadMapUrlRespectsTheBasePath(): void {
    $this->drupalGet('<front>');
    $this->assertSession()->waitForElement('css', '[data-region-id="CA"]');

    // Pretend Drupal is installed at /subdir/, stub Drupal.ajax so nothing
    // is actually requested, then run the load-map action against a
    // throwaway element and capture the URL it would have called.
    $requestedUrl = $this->getSession()->evaluateScript(<<<'JS'
      (function () {
        drupalSettings.path.baseUrl = '/subdir/';

        const originalAjax = Drupal.ajax;
        let captured = null;
        Drupal.ajax = function (settings) {
          captured = settings.url;
          return { execute: () => Promise.resolve() };
        };

        try {
          const element = document.createElement('div');
          Drupal.simple_interactive_maps.ajax_load_map(
            element,
            { target_map_id: 'test_map_2' },
            'CA'
          );
          element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
        } finally {
          Drupal.ajax = originalAjax;
        }

        return captured;
      }())
    JS);

    $this->assertSame('/subdir/simple-interactive-maps/ajax', $requestedUrl);
  }

  /**
   * Regression guard for the "button duplication" bug.
   *
   * Fixed once before in this module's history (see git log: "Fixes the
   * button duplication error in the map load action javascript").
   * map.action.load-map.js keeps its back-link bookkeeping on a single
   * global window.simBackLinks array, so two independent maps with
   * ajax_load_map regions on the same page is the scenario that previously
   * produced duplicate/incorrect back-links.
   */
  public function testTwoMapsOnOnePageDoNotProduceDuplicateBackLinks(): void {
    // A second, independently-placed map alongside the one from setUp().
    $secondMapRegions = $this->buildDefaultRegions();
    $secondMapRegions['NY']['action'] = [
      'plugin_id' => 'ajax_load_map',
      'plugin_configuration' => ['target_map_id' => 'test_map'],
    ];
    InteractiveMap::create([
      'id' => 'test_map_3',
      'label' => 'Test Map 3',
      'base_map' => 'us_states_territories',
      'regions' => $secondMapRegions,
    ])->save();

    $this->drupalPlaceBlock('simple_interactive_maps_interactive_map', [
      'region' => 'sidebar_first',
      'map' => 'test_map_3',
      'show_description' => FALSE,
    ]);

    $this->drupalGet('<front>');
    $this->assertSession()->waitForElement('css', '[data-region-id="CA"]');

    $this->assertCount(2, $this->getSession()->getPage()->findAll('css', '.interactive_map__wrapper'));

    // Waiting for the CA region above only proves the *first* map (from
    // setUp()) has had its behaviors attached; it says nothing about this
    // second, independently placed block/map. map.action.load-map.js sets
    // role="link" synchronously right after binding its click listener, so
    // this is a reliable readiness signal specifically for test_map_3's NY
    // region before we dispatch a click at it - without this, clicking NY
    // immediately after only the first map was confirmed ready is a race
    // that a slower environment (e.g. drupal.org's CI runners) can lose.
    $this->getSession()->wait(10000, "document.querySelector('[data-map-id=\"test_map_3\"] [data-region-id=\"NY\"]')?.getAttribute('role') === 'link'");

    // Click the ajax_load_map region on the second map only.
    $this->getSession()->executeScript(
      "document.querySelector('[data-map-id=\"test_map_3\"] [data-region-id=\"NY\"]').dispatchEvent(new MouseEvent('click', {bubbles: true}));"
    );

    // See the comment in testClickingAnAjaxLoadMapRegionSwapsTheMap() for why
    // this polls for the resulting DOM state rather than using Drupal's
    // assertWaitOnAjaxRequest().
    $this->getSession()->wait(30000, "document.querySelectorAll('.interactive_map__back-link').length > 0");

    // Exactly one back-link should exist, not one per map on the page.
    $this->assertCount(1, $this->getSession()->getPage()->findAll('css', '.interactive_map__back-link'));
  }

  /**
   * Gets the region element with the given data-region-id.
   */
  private function getRegion(string $regionId): NodeElement {
    $element = $this->getSession()->getPage()->find('css', sprintf('[data-region-id="%s"]', $regionId));
    $this->assertNotNull($element, "Region $regionId was not found on the page.");
    return $element;
  }

}
