<?php // phpcs:ignore SlevomatCodingStandard.TypeHints.DeclareStrictTypes.DeclareStrictTypesMissing

namespace MailPoet\Segments;

if (!defined('ABSPATH')) exit;


use DateTime;
use MailPoet\ConflictException;
use MailPoet\Doctrine\Repository;
use MailPoet\Entities\DynamicSegmentFilterData;
use MailPoet\Entities\DynamicSegmentFilterEntity;
use MailPoet\Entities\NewsletterSegmentEntity;
use MailPoet\Entities\SegmentEntity;
use MailPoet\Entities\SubscriberEntity;
use MailPoet\Entities\SubscriberSegmentEntity;
use MailPoet\Form\FormsRepository;
use MailPoet\InvalidStateException;
use MailPoet\Logging\LoggerFactory;
use MailPoet\Newsletter\Segment\NewsletterSegmentRepository;
use MailPoet\NotFoundException;
use MailPoet\Subscribers\SegmentsCountRecalculator;
use MailPoet\WP\Functions as WPFunctions;
use MailPoetVendor\Carbon\Carbon;
use MailPoetVendor\Doctrine\DBAL\ArrayParameterType;
use MailPoetVendor\Doctrine\DBAL\ParameterType;
use MailPoetVendor\Doctrine\ORM\EntityManager;
use MailPoetVendor\Doctrine\ORM\ORMException;

/**
 * @extends Repository<SegmentEntity>
 */
class SegmentsRepository extends Repository {

  /** @var NewsletterSegmentRepository */
  private $newsletterSegmentRepository;

  /** @var FormsRepository */
  private $formsRepository;

  /** @var WPFunctions */
  private $wp;

  /** @var LoggerFactory */
  private $loggerFactory;

  /** @var SegmentsCountRecalculator */
  private $segmentsCountRecalculator;

  public function __construct(
    EntityManager $entityManager,
    NewsletterSegmentRepository $newsletterSegmentRepository,
    FormsRepository $formsRepository,
    WPFunctions $wp,
    LoggerFactory $loggerFactory,
    SegmentsCountRecalculator $segmentsCountRecalculator
  ) {
    parent::__construct($entityManager);
    $this->newsletterSegmentRepository = $newsletterSegmentRepository;
    $this->formsRepository = $formsRepository;
    $this->wp = $wp;
    $this->loggerFactory = $loggerFactory;
    $this->segmentsCountRecalculator = $segmentsCountRecalculator;
  }

  protected function getEntityClassName() {
    return SegmentEntity::class;
  }

  /**
   * @param string[] $types
   * @return SegmentEntity[]
   */
  public function findByTypeNotIn(array $types): array {
    return $this->doctrineRepository->createQueryBuilder('s')
      ->select('s')
      ->where('s.type NOT IN (:types)')
      ->setParameter('types', $types)
      ->getQuery()
      ->getResult();
  }

  public function getWPUsersSegment(): SegmentEntity {
    $cached = current(
      array_filter(
        $this->getAllFromIdentityMap(),
        fn(SegmentEntity $segment) => $segment->getType() === SegmentEntity::TYPE_WP_USERS
      )
    );

    if ($cached) {
      return $cached;
    }

    $segment = $this->findOneBy(['type' => SegmentEntity::TYPE_WP_USERS]);
    if (!$segment) {
      // create the wp users segment
      $segment = new SegmentEntity(
        __('WordPress Users', 'mailpoet'),
        SegmentEntity::TYPE_WP_USERS,
        __('This list contains all of your WordPress users.', 'mailpoet')
      );
      $this->entityManager->persist($segment);
      $this->entityManager->flush();
    }
    return $segment;
  }

  public function getWooCommerceSegment(): SegmentEntity {
    $cached = current(
      array_filter(
        $this->getAllFromIdentityMap(),
        fn(SegmentEntity $segment) => $segment->getType() === SegmentEntity::TYPE_WC_USERS
      )
    );

    if ($cached) {
      return $cached;
    }

    $segment = $this->findOneBy(['type' => SegmentEntity::TYPE_WC_USERS]);
    if (!$segment) {
      // create the WooCommerce customers segment
      $segment = new SegmentEntity(
        __('WooCommerce Customers', 'mailpoet'),
        SegmentEntity::TYPE_WC_USERS,
        __('This list contains all of your WooCommerce customers.', 'mailpoet')
      );
      $this->entityManager->persist($segment);
      $this->entityManager->flush();
    }
    return $segment;
  }

  public function getCountsPerType(): array {
    $results = $this->doctrineRepository->createQueryBuilder('s')
      ->select('s.type, COUNT(s) as cnt')
      ->where('s.deletedAt IS NULL')
      ->groupBy('s.type')
      ->getQuery()
      ->getResult();

    $countMap = [];
    foreach ($results as $result) {
      $countMap[$result['type']] = (int)$result['cnt'];
    }
    return $countMap;
  }

  public function isNameUnique(string $name, ?int $id): bool {
    $qb = $this->doctrineRepository->createQueryBuilder('s')
      ->select('s')
      ->where('s.name = :name')
      ->setParameter('name', $name);

    if ($id !== null) {
      $qb->andWhere('s.id != :id')
        ->setParameter('id', $id);
    }

    $results = $qb->getQuery()
      ->getResult();

    return count($results) === 0;
  }

  /**
   * @throws ConflictException
   */
  public function verifyNameIsUnique(string $name, ?int $id): void {
    if (!$this->isNameUnique($name, $id)) {
      throw new ConflictException("Could not create new segment with name [{$name}] because a segment with that name already exists.");
    }
  }

  /**
   * @param int $id
   *
   * @return SegmentEntity
   * @throws InvalidStateException
   */
  public function verifyDynamicSegmentExists(int $id): SegmentEntity {
    try {
      $dynamicSegment = $this->findOneById($id);
      if (!$dynamicSegment instanceof SegmentEntity) {
        throw InvalidStateException::create()->withMessage(sprintf("Could not find segment with ID '%s'.", $id));
      }
      if ($dynamicSegment->getType() !== SegmentEntity::TYPE_DYNAMIC) {
        throw InvalidStateException::create()->withMessage(sprintf("Segment with ID '%s' is not a dynamic segment. Its type is %s.", $id, $dynamicSegment->getType()));
      }
    } catch (InvalidStateException $exception) {
      $this->loggerFactory->getLogger(LoggerFactory::TOPIC_SEGMENTS)->error(sprintf("Could not verify existence of dynamic segment: %s", $exception->getMessage()));
      throw $exception;
    }
    return $dynamicSegment;
  }

  /**
   * @param DynamicSegmentFilterData[] $filtersData
   * @throws ConflictException
   * @throws NotFoundException
   * @throws ORMException
   */
  public function createOrUpdate(
    string $name,
    string $description = '',
    string $type = SegmentEntity::TYPE_DEFAULT,
    array $filtersData = [],
    ?int $id = null,
    bool $displayInManageSubscriptionPage = true,
    ?int $confirmationEmailId = null,
    ?int $confirmationPageId = null,
    ?string $publicDescription = null
  ): SegmentEntity {
    if ($confirmationEmailId !== null && $confirmationEmailId <= 0) {
      $confirmationEmailId = null;
    }
    if ($confirmationPageId !== null && $confirmationPageId <= 0) {
      $confirmationPageId = null;
    }
    $displayInManageSubPage = $type === SegmentEntity::TYPE_DEFAULT ? $displayInManageSubscriptionPage : false;

    if ($id) {
      $segment = $this->findOneById($id);
      if (!$segment instanceof SegmentEntity) {
        throw new NotFoundException("Segment with ID [{$id}] was not found.");
      }
      if ($name !== $segment->getName()) {
        $this->verifyNameIsUnique($name, $id);
        $segment->setName($name);
      }
      $segment->setDescription($description);
      $segment->setDisplayInManageSubscriptionPage($displayInManageSubPage);
      $segment->setConfirmationEmailId($confirmationEmailId);
      $segment->setConfirmationPageId($confirmationPageId);
      if ($publicDescription !== null) {
        $segment->setPublicDescription($publicDescription);
      }
    } else {
      $this->verifyNameIsUnique($name, $id);
      $segment = new SegmentEntity($name, $type, $description);
      $segment->setDisplayInManageSubscriptionPage($displayInManageSubPage);
      $segment->setConfirmationEmailId($confirmationEmailId);
      $segment->setConfirmationPageId($confirmationPageId);
      $segment->setPublicDescription($publicDescription ?? '');
      $this->persist($segment);
    }

    // We want to remove redundant filters before update
    while ($segment->getDynamicFilters()->count() > count($filtersData)) {
      $filterEntity = $segment->getDynamicFilters()->last();
      if ($filterEntity) {
        $segment->getDynamicFilters()->removeElement($filterEntity);
        $this->entityManager->remove($filterEntity);
      }
    }

    $createOrUpdateFilter = function ($filterData, $key) use ($segment) {
      if ($filterData instanceof DynamicSegmentFilterData) {
        $filterEntity = $segment->getDynamicFilters()->get($key);
        if (!$filterEntity instanceof DynamicSegmentFilterEntity) {
          $filterEntity = new DynamicSegmentFilterEntity($segment, $filterData);
          $segment->getDynamicFilters()->add($filterEntity);
          $this->entityManager->persist($filterEntity);
        } else {
          $filterEntity->setFilterData($filterData);
        }
      }
    };

    $wpActionName = 'mailpoet_dynamic_segments_filters_save';
    if ($this->wp->hasAction($wpActionName)) {
      $this->wp->doAction($wpActionName, $createOrUpdateFilter, $filtersData);
    } else {
      $filterData = reset($filtersData);
      $key = key($filtersData);
      $createOrUpdateFilter($filterData, $key);
    }

    $this->flush();
    return $segment;
  }

  public function bulkDelete(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
    if (empty($ids)) {
      return 0;
    }

    // Dynamic segments never materialize memberships in subscriber_segment, so
    // deleting them cannot change any subscriber's segments_count. Skip the
    // capture (and the recalc below) for them.
    $isDynamic = $type === SegmentEntity::TYPE_DYNAMIC;

    // Capture the affected subscribers before the cascade removes their
    // memberships, so segments_count can be refreshed for them afterwards. When
    // there are too many to recompute inline, skip the (potentially huge)
    // capture and let the background sweep reconcile their counts instead.
    $deferRecalculation = false;
    $affectedSubscriberIds = [];
    if (!$isDynamic) {
      $deferRecalculation = $this->segmentsCountRecalculator->countSegmentMembers($ids, true, $type) >= $this->segmentsCountRecalculator->getDeferThreshold();
      if (!$deferRecalculation) {
        $affectedSubscriberIds = $this->getSubscriberIdsForSegments($ids, $type);
      }
    }

    $count = 0;
    $this->entityManager->transactional(function (EntityManager $entityManager) use ($ids, $type, &$count) {
      $subscriberSegmentTable = $entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
      $segmentTable = $entityManager->getClassMetadata(SegmentEntity::class)->getTableName();
      $segmentFiltersTable = $entityManager->getClassMetadata(DynamicSegmentFilterEntity::class)->getTableName();

      $entityManager->getConnection()->executeStatement("
         DELETE ss FROM $subscriberSegmentTable ss
         JOIN $segmentTable s ON ss.`segment_id` = s.`id`
         WHERE ss.`segment_id` IN (:ids)
         AND s.`type` = :type
      ", [
        'ids' => $ids,
        'type' => $type,
      ], ['ids' => ArrayParameterType::INTEGER]);

      $entityManager->getConnection()->executeStatement("
         DELETE df FROM $segmentFiltersTable df
         WHERE df.`segment_id` IN (:ids)
      ", [
        'ids' => $ids,
      ], ['ids' => ArrayParameterType::INTEGER]);

      $queryBuilder = $entityManager->createQueryBuilder();
      $count = $queryBuilder->delete(SegmentEntity::class, 's')
        ->where('s.id IN (:ids)')
        ->andWhere('s.type = :type')
        ->setParameter('ids', $ids, ArrayParameterType::INTEGER)
        ->setParameter('type', $type, ParameterType::STRING)
        ->getQuery()->execute();

      $queryBuilder = $entityManager->createQueryBuilder();
      $queryBuilder->delete(NewsletterSegmentEntity::class, 'ns')
        ->where('ns.segment IN (:ids)')
        ->setParameter('ids', $ids, ArrayParameterType::INTEGER)
        ->getQuery()->execute();
    });

    if (!$isDynamic) {
      if ($deferRecalculation) {
        $this->segmentsCountRecalculator->scheduleBackgroundRecalculation();
      } else {
        $this->segmentsCountRecalculator->recalculateForSubscribers($affectedSubscriberIds);
      }
    }

    return $count;
  }

  /**
   * @param int[] $segmentIds
   * @return int[]
   */
  private function getSubscriberIdsForSegments(array $segmentIds, string $type): array {
    if (empty($segmentIds)) {
      return [];
    }

    $subscriberSegmentTable = $this->entityManager->getClassMetadata(SubscriberSegmentEntity::class)->getTableName();
    $segmentTable = $this->entityManager->getClassMetadata(SegmentEntity::class)->getTableName();

    $subscribedStatus = SubscriberEntity::STATUS_SUBSCRIBED;
    $ids = $this->entityManager->getConnection()->executeQuery("
       SELECT DISTINCT ss.`subscriber_id` FROM $subscriberSegmentTable ss
       JOIN $segmentTable s ON ss.`segment_id` = s.`id`
       WHERE ss.`segment_id` IN (:ids)
       AND s.`type` = :type
       AND ss.`status` = :subscribedStatus
    ", [
      'ids' => $segmentIds,
      'type' => $type,
      'subscribedStatus' => $subscribedStatus,
    ], ['ids' => ArrayParameterType::INTEGER])->fetchFirstColumn();

    return array_map(function ($id): int {
      return is_numeric($id) ? (int)$id : 0;
    }, $ids);
  }

  public function bulkTrash(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
    $activelyUsedInNewsletters = $this->newsletterSegmentRepository->getSubjectsOfActivelyUsedEmailsForSegments($ids);
    $activelyUsedInForms = $this->formsRepository->getNamesOfFormsForSegments();
    $activelyUsed = array_unique(array_merge(array_keys($activelyUsedInNewsletters), array_keys($activelyUsedInForms)));
    $ids = array_diff($ids, $activelyUsed);
    return $this->updateDeletedAt($ids, new Carbon(), $type);
  }

  public function doTrash(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
    return $this->updateDeletedAt($ids, new Carbon(), $type);
  }

  public function bulkRestore(array $ids, string $type = SegmentEntity::TYPE_DEFAULT): int {
    return $this->updateDeletedAt($ids, null, $type);
  }

  private function updateDeletedAt(array $ids, ?DateTime $deletedAt, string $type): int {
    if (empty($ids)) {
      return 0;
    }

    $rows = $this->entityManager->createQueryBuilder()->update(SegmentEntity::class, 's')
    ->set('s.deletedAt', ':deletedAt')
    ->where('s.id IN (:ids)')
    ->andWhere('s.type IN (:type)')
    ->setParameter('deletedAt', $deletedAt)
    ->setParameter('ids', $ids)
    ->setParameter('type', $type)
    ->getQuery()->execute();

    // Trashing or restoring a segment changes whether its memberships count
    // towards segments_count, so refresh every affected subscriber. The
    // memberships still exist here (only deleted_at changed), so walk them in
    // keyset-paginated batches instead of materializing every member id at once.
    // Dynamic segments have no materialized memberships, so there is nothing to
    // recalculate for them.
    if ($type !== SegmentEntity::TYPE_DYNAMIC) {
      $this->segmentsCountRecalculator->recalculateForSegments($ids);
    }

    return $rows;
  }

  public function findByUpdatedScoreNotInLastDay(int $limit): array {
    $dateTime = (new Carbon())->subDay();
    return $this->entityManager->createQueryBuilder()
      ->select('s')
      ->from(SegmentEntity::class, 's')
      ->where('s.averageEngagementScoreUpdatedAt IS NULL')
      ->orWhere('s.averageEngagementScoreUpdatedAt < :dateTime')
      ->setParameter('dateTime', $dateTime)
      ->getQuery()
      ->setMaxResults($limit)
      ->getResult();
  }

  /**
   * Returns count of segments that have more than one dynamic filter
   */
  public function getSegmentCountWithMultipleFilters(): int {
    $segmentFiltersTable = $this->entityManager->getClassMetadata(DynamicSegmentFilterEntity::class)->getTableName();
    $qbInner = $this->entityManager->getConnection()->createQueryBuilder()
      ->select('COUNT(DISTINCT sf.id) AS segmentCount')
      ->from($segmentFiltersTable, 'sf')
      ->groupBy('sf.segment_id')
      ->having('COUNT(sf.id) > 1');
    /** @var null|int $result */
    $result = $this->entityManager->getConnection()->createQueryBuilder()
      ->select('count(*)')
      ->from(sprintf('(%s) as subCounts', $qbInner->getSQL()))
      ->execute()
      ->fetchOne();
    return (int)$result;
  }
}
