<?php

declare(strict_types=1);

namespace Drupal\flowdrop_node_processor\Plugin\FlowDropNodeProcessor;

use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Entity\FieldableEntityInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Entity\RevisionLogInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\flowdrop\Attribute\FlowDropNodeProcessor;
use Drupal\flowdrop\DTO\ExecutionContextDTO;
use Drupal\flowdrop\DTO\ParameterBagInterface;
use Drupal\flowdrop\DTO\ValidationError;
use Drupal\flowdrop\DTO\ValidationResult;
use Drupal\flowdrop\Exception\EntityProcessingException;
use Drupal\flowdrop\Exception\InvalidNodeConfigurationException;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\AbstractFlowDropNodeProcessor;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ExecutionContextAwareInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\HasSideEffectsInterface;
use Drupal\flowdrop\Service\EntitySerializer;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Executor for Entity Save nodes.
 *
 * Creates or updates Drupal entities. When an entity_id is provided,
 * the existing entity is loaded and updated. Otherwise, a new entity
 * is created with the provided field values.
 *
 * @internal This class is not part of the public API and may change without notice.
 */
#[FlowDropNodeProcessor(
  id: "entity_save",
  label: new TranslatableMarkup("Entity Save"),
  description: "Create or update Drupal entities",
  version: "1.0.0"
)]
class EntitySave extends AbstractFlowDropNodeProcessor implements ExecutionContextAwareInterface, HasSideEffectsInterface {

  /**
   * The execution context injected by the runtime.
   *
   * @var \Drupal\flowdrop\DTO\ExecutionContextDTO|null
   */
  protected ?ExecutionContextDTO $executionContext = NULL;

  /**
   * Constructs an EntitySave object.
   *
   * @param array<string, mixed> $configuration
   *   The plugin configuration.
   * @param string $plugin_id
   *   The plugin ID.
   * @param mixed $plugin_definition
   *   The plugin definition.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
   *   The entity type manager service.
   * @param \Drupal\flowdrop\Service\EntitySerializer $entitySerializer
   *   The entity serializer service.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerFactory
   *   The logger channel factory.
   */
  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly EntitySerializer $entitySerializer,
    private readonly LoggerChannelFactoryInterface $loggerFactory,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ): static {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get("entity_type.manager"),
      $container->get("flowdrop.entity_serializer"),
      $container->get("logger.factory"),
    );
  }

  /**
   * {@inheritdoc}
   */
  public function setExecutionContext(ExecutionContextDTO $context): void {
    $this->executionContext = $context;
  }

  /**
   * {@inheritdoc}
   */
  public function validateParams(array $params): ValidationResult {
    $errors = [];

    // Validate entity_type parameter - must be a non-empty string.
    if (empty($params["entity_type"])) {
      $errors[] = new ValidationError("entity_type", "Entity type is required");
    }
    elseif (!is_string($params["entity_type"])) {
      $errors[] = new ValidationError("entity_type", "Entity type must be a string");
    }
    else {
      // Check if the entity type exists.
      try {
        $this->entityTypeManager->getDefinition($params["entity_type"]);
      }
      catch (\Exception $e) {
        $errors[] = new ValidationError("entity_type", "Invalid entity type: {$params["entity_type"]}");
      }
    }

    // Validate entity_id if provided - must be a string or integer.
    if (isset($params["entity_id"]) && $params["entity_id"] !== "") {
      if (!is_string($params["entity_id"]) && !is_int($params["entity_id"])) {
        $errors[] = new ValidationError("entity_id", "Entity ID must be a string or integer");
      }
    }

    // Validate values parameter - must be an array.
    if (isset($params["values"]) && !is_array($params["values"])) {
      $errors[] = new ValidationError("values", "Values must be an array/object of field values");
    }

    // Patch mode requires entity_id to load the existing entity.
    $patchMode = !empty($params["patch_mode"]);
    $entityId = $params["entity_id"] ?? "";
    if ($patchMode && empty($entityId)) {
      $errors[] = new ValidationError(
        "entity_id",
        "Entity ID is required when patch_mode is enabled. Provide entity_id to load and update an existing entity."
      );
    }

    if (!empty($errors)) {
      return ValidationResult::failure($errors);
    }

    return ValidationResult::success();
  }

  /**
   * {@inheritdoc}
   */
  public function process(ParameterBagInterface $params): array {
    $entityType = $params->getString("entity_type", "");
    $entityId = $params->get("entity_id", "");
    $values = $params->getArray("values", []);
    $bundle = $params->getString("bundle", "");
    $allowedFields = $params->getArray("allowed_fields", []);
    $patchMode = (bool) $params->get("patch_mode", FALSE);
    $newRevision = (bool) $params->get("new_revision", FALSE);
    $revisionLogMessage = $params->getString("revision_log_message", "");

    // Validate entity type exists.
    if (empty($entityType)) {
      throw new InvalidNodeConfigurationException("Entity type is required");
    }

    // Check if entity type is valid.
    try {
      $definition = $this->entityTypeManager->getDefinition($entityType);
    }
    catch (\Exception $e) {
      throw new InvalidNodeConfigurationException("Invalid entity type: {$entityType}", 0, $e);
    }

    $storage = $this->entityTypeManager->getStorage($entityType);
    $isNew = TRUE;
    $entity = NULL;

    // Patch mode: entity_id is required so we can load the existing entity
    // and merge only the allowed fields. Without loading the original entity,
    // partial data would trigger Drupal's entity validation constraints on
    // required fields (e.g. title).
    if ($patchMode && empty($entityId)) {
      throw new InvalidNodeConfigurationException(
        "Entity ID is required when patch_mode is enabled. " .
        "Provide entity_id to load and update an existing entity."
      );
    }

    // Determine if we're updating or creating.
    if (!empty($entityId)) {
      // Load existing entity for update/patch.
      // @todo Support loading a specific revision via a revision_id parameter.
      $entity = $storage->load($entityId);

      if ($entity === NULL) {
        throw new EntityProcessingException("Entity not found: {$entityType} with ID {$entityId}");
      }

      $isNew = FALSE;

      // Filter values based on allowed_fields if configured.
      // Only the allowed fields from the incoming values are merged onto
      // the loaded entity — all other existing field values are preserved.
      $filteredValues = $this->filterAllowedFields($values, $allowedFields);

      // Update existing entity fields (only for fieldable entities).
      if ($entity instanceof FieldableEntityInterface) {
        foreach ($filteredValues as $field => $value) {
          if ($entity->hasField($field)) {
            $entity->set($field, $value);
          }
        }
      }
    }
    else {
      // Create new entity with all provided values.
      $createValues = $values;

      // Add bundle if required and provided.
      $bundleKey = $definition->getKey("bundle");
      if ($bundleKey && !empty($bundle)) {
        $createValues[$bundleKey] = $bundle;
      }
      elseif ($bundleKey && !isset($createValues[$bundleKey])) {
        // Bundle is required but not provided.
        throw new InvalidNodeConfigurationException("Bundle is required for entity type: {$entityType}");
      }

      $entity = $storage->create($createValues);
    }

    // Force new revision if requested and entity supports revisions.
    if ($newRevision && $entity instanceof RevisionableInterface) {
      $entity->setNewRevision(TRUE);
      if (!empty($revisionLogMessage) && $entity instanceof RevisionLogInterface) {
        $entity->setRevisionLogMessage($revisionLogMessage);
      }
    }

    // Perform access check if enabled.
    // Legacy compat: access_check:false maps to 'bypass' regardless of mode.
    $accessCheck = $params->getBool("access_check", TRUE);
    $accessCheckUser = $accessCheck
      ? $params->getString("access_check_user", "triggering_user")
      : "bypass";

    if ($accessCheckUser !== "bypass") {
      $account = $this->resolveAccessAccount($accessCheckUser, $params);
      $op = $entity->isNew() ? 'create' : 'update';
      if (!$entity->access($op, $account)) {
        throw new EntityProcessingException(
          sprintf('Access denied: cannot %s entity of type %s', $op, $entity->getEntityTypeId())
        );
      }
    }

    // Save the entity.
    $entity->save();

    // Get the saved entity ID.
    $savedId = $entity->id();

    // Calculate skipped fields (fields in values but not in allowed_fields).
    $skippedFields = $this->getSkippedFields($values, $allowedFields);

    // Serialize the entity in the same format as Entity Trigger would provide.
    $serializedEntity = $this->entitySerializer->serialize($entity);

    // Prepare output with entity information.
    // The 'entity' field provides the full serialized entity (trigger format).
    $output = [
      "entity_id" => $savedId,
      "entity_type" => $entityType,
      "is_new" => $isNew,
      "label" => $entity->label() ?? "",
      "uuid" => $entity->uuid(),
      "created" => method_exists($entity, "getCreatedTime") ? $entity->getCreatedTime() : NULL,
      "changed" => method_exists($entity, "getChangedTime") ? $entity->getChangedTime() : NULL,
      "revision_id" => $entity instanceof RevisionableInterface ? $entity->getRevisionId() : NULL,
      "skipped_fields" => $skippedFields,
      "entity" => $serializedEntity,
    ];

    // Add bundle information if applicable.
    $bundleKey = $definition->getKey("bundle");
    if ($bundleKey) {
      $output["bundle"] = $entity->bundle();
    }

    return $output;
  }

  /**
   * Resolves the account to use for the access check.
   *
   * @param string $mode
   *   The access_check_user mode: 'triggering_user', 'specific_user', or
   *   'current_system_user'.
   * @param \Drupal\flowdrop\DTO\ParameterBagInterface $params
   *   The parameter bag for reading access_check_user_id when needed.
   *
   * @return \Drupal\Core\Session\AccountInterface|null
   *   The account to pass to $entity->access(), or NULL to use Drupal's
   *   ambient current user (current_system_user mode).
   */
  private function resolveAccessAccount(string $mode, ParameterBagInterface $params): ?AccountInterface {
    return match ($mode) {
      "triggering_user" => $this->loadTriggeringUser(),
      "specific_user" => $this->loadSpecificUser((int) $params->get("access_check_user_id", 0)),
      default => NULL,
    };
  }

  /**
   * Loads the user account of the workflow's triggering user.
   *
   * Reads the UID from initialData['user']['uid'], which is populated by
   * FormHooks and other trigger handlers at the time the workflow is triggered.
   * If no UID is available (e.g. REST-triggered or background-only workflow),
   * logs a warning and returns NULL, falling back to Drupal's current user.
   *
   * @return \Drupal\Core\Session\AccountInterface|null
   *   The triggering user's account, or NULL when unavailable.
   */
  private function loadTriggeringUser(): ?AccountInterface {
    $userContext = $this->executionContext?->getInitialDataValue('user');
    $uid = is_array($userContext) ? ($userContext['uid'] ?? NULL) : NULL;

    if (empty($uid)) {
      $workflowId = $this->executionContext?->getWorkflowId() ?? 'unknown';
      $this->loggerFactory->get('flowdrop_node_processor')->warning(
        'EntitySave: triggering_user mode selected but no user.uid found in execution context. Falling back to current system user for workflow @workflow_id.',
        ['@workflow_id' => $workflowId]
      );
      return NULL;
    }

    $account = $this->entityTypeManager->getStorage('user')->load($uid);
    return $account instanceof AccountInterface ? $account : NULL;
  }

  /**
   * Loads a specific user account by UID for the access check.
   *
   * @param int $uid
   *   The user ID configured by the site builder.
   *
   * @return \Drupal\Core\Session\AccountInterface
   *   The loaded user account.
   *
   * @throws \Drupal\flowdrop\Exception\InvalidNodeConfigurationException
   *   When the UID is 0 or the user account cannot be found.
   */
  private function loadSpecificUser(int $uid): AccountInterface {
    if ($uid === 0) {
      throw new InvalidNodeConfigurationException(
        "access_check_user is set to 'specific_user' but no access_check_user_id was provided."
      );
    }

    $account = $this->entityTypeManager->getStorage('user')->load($uid);
    if (!$account instanceof AccountInterface) {
      throw new InvalidNodeConfigurationException(
        "access_check_user_id {$uid} does not correspond to an existing user account."
      );
    }

    return $account;
  }

  /**
   * Filters field values based on the allowed fields configuration.
   *
   * @param array<string, mixed> $values
   *   The field values to filter.
   * @param array<int, string> $allowedFields
   *   The list of allowed field names. Empty means all fields allowed.
   *
   * @return array<string, mixed>
   *   The filtered field values.
   */
  private function filterAllowedFields(array $values, array $allowedFields): array {
    // If allowed_fields is empty, allow all fields.
    if (empty($allowedFields)) {
      return $values;
    }

    // Filter to only include allowed fields.
    return array_filter(
      $values,
      fn(string $key): bool => in_array($key, $allowedFields, TRUE),
      ARRAY_FILTER_USE_KEY
    );
  }

  /**
   * Gets the list of fields that were skipped due to allowed_fields config.
   *
   * @param array<string, mixed> $values
   *   The original field values.
   * @param array<int, string> $allowedFields
   *   The list of allowed field names. Empty means all fields allowed.
   *
   * @return array<int, string>
   *   The list of field names that were skipped.
   */
  private function getSkippedFields(array $values, array $allowedFields): array {
    // If allowed_fields is empty, no fields are skipped.
    if (empty($allowedFields)) {
      return [];
    }

    // Return fields that are in values but not in allowed_fields.
    return array_values(
      array_filter(
        array_keys($values),
        fn(string $key): bool => !in_array($key, $allowedFields, TRUE)
      )
    );
  }

  /**
   * {@inheritdoc}
   */
  public function getParameterSchema(): array {
    return [
      "type" => "object",
      "properties" => [
        "entity_type" => [
          "type" => "string",
          "title" => "Entity Type",
          "description" => "The entity type to create/update (e.g., node, user, taxonomy_term)",
          "default" => "node",
        ],
        "bundle" => [
          "type" => "string",
          "title" => "Bundle",
          "description" => "The bundle/content type for the entity (required for new entities with bundles)",
          "default" => "",
        ],
        "entity_id" => [
          "type" => "string",
          "title" => "Entity ID",
          "description" => "The ID of an existing entity to update. Required when patch_mode is enabled. Leave empty to create new.",
          "default" => "",
        ],
        "values" => [
          "type" => "object",
          "format" => "json",
          "title" => "Field Values",
          "description" => "The field values to set on the entity as key-value pairs. In patch mode, only allowed_fields are applied to the loaded entity.",
          "default" => [],
        ],
        "allowed_fields" => [
          "type" => "array",
          "title" => "Allowed Fields",
          "description" => "List of field names that are allowed to be updated. Leave empty to allow all fields. Used with patch_mode to apply partial updates.",
          "default" => [],
          "items" => [
            "type" => "string",
          ],
        ],
        "patch_mode" => [
          "type" => "boolean",
          "title" => "Patch Mode",
          "description" => "When enabled, loads the existing entity by entity_id and merges only the allowed field values. Existing field values are preserved for fields not in the update. Requires entity_id.",
          "default" => FALSE,
        ],
        "new_revision" => [
          "type" => "boolean",
          "title" => "New Revision",
          "description" => "Force creation of a new revision when the entity supports revisions",
          "default" => FALSE,
        ],
        "revision_log_message" => [
          "type" => "string",
          "title" => "Revision Log Message",
          "description" => "Log message for the new revision (only used when new_revision is enabled)",
          "default" => "",
        ],
        "access_check" => [
          "type" => "boolean",
          "title" => "Access check",
          "description" => "When enabled, checks whether the configured user account can perform the operation. Disable with caution for system-level workflows.",
          "default" => TRUE,
        ],
        "access_check_user" => [
          "type" => "string",
          "title" => "Access check — run as",
          "description" => "Which user account to use for the access check. 'triggering_user' reads the UID from the workflow's initial data (recommended for user-initiated workflows). 'current_system_user' uses Drupal's ambient user — unreliable in queued or cron context. 'specific_user' uses a fixed UID configured below. 'bypass' skips the check entirely.",
          "default" => "triggering_user",
          "enum" => [
            "triggering_user",
            "current_system_user",
            "specific_user",
            "bypass",
          ],
        ],
        "access_check_user_id" => [
          "type" => "integer",
          "title" => "Specific user ID",
          "description" => "UID of the user account to run the access check as. Only used when 'Access check — run as' is set to 'specific_user'.",
          "default" => 0,
        ],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getOutputSchema(): array {
    return [
      "type" => "object",
      "properties" => [
        "entity_id" => [
          "type" => "string",
          "description" => "The ID of the saved entity",
        ],
        "entity_type" => [
          "type" => "string",
          "description" => "The entity type",
        ],
        "bundle" => [
          "type" => "string",
          "description" => "The entity bundle (if applicable)",
        ],
        "is_new" => [
          "type" => "boolean",
          "description" => "Whether a new entity was created (true) or existing was updated (false)",
        ],
        "label" => [
          "type" => "string",
          "description" => "The label/title of the entity",
        ],
        "uuid" => [
          "type" => "string",
          "description" => "The UUID of the entity",
        ],
        "created" => [
          "type" => "integer",
          "description" => "The creation timestamp (if available)",
        ],
        "changed" => [
          "type" => "integer",
          "description" => "The last changed timestamp (if available)",
        ],
        "revision_id" => [
          "type" => "string",
          "description" => "The revision ID of the saved entity (if revisionable)",
        ],
        "skipped_fields" => [
          "type" => "array",
          "description" => "List of field names that were skipped due to allowed_fields restriction",
        ],
        "entity" => [
          "type" => "object",
          "description" => "Full serialized entity data in the same format as Entity Trigger output",
          "additionalProperties" => TRUE,
          "properties" => [
            "entity_type" => [
              "type" => "string",
              "description" => "The entity type ID",
            ],
            "id" => [
              "type" => "string",
              "description" => "The entity ID",
            ],
            "uuid" => [
              "type" => "string",
              "description" => "The entity UUID",
            ],
            "bundle" => [
              "type" => "string",
              "description" => "The entity bundle",
            ],
            "label" => [
              "type" => "string",
              "description" => "The entity label",
            ],
            "langcode" => [
              "type" => "string",
              "description" => "The entity language code",
            ],
            "fields" => [
              "type" => "object",
              "description" => "All non-computed field values as key-value pairs",
              "additionalProperties" => TRUE,
            ],
          ],
        ],
      ],
    ];
  }

}
