<?php

declare(strict_types=1);

namespace MauticPlugin\DealFlowBundle\Service;

use Doctrine\ORM\EntityManagerInterface;
use MauticPlugin\DealFlowBundle\DealFlowEvents;
use MauticPlugin\DealFlowBundle\Entity\Deal;
use MauticPlugin\DealFlowBundle\Entity\DealAttachment;
use MauticPlugin\DealFlowBundle\Entity\DealAuditEvent;
use MauticPlugin\DealFlowBundle\Entity\DealContactAssociation;
use MauticPlugin\DealFlowBundle\Entity\DealNote;
use MauticPlugin\DealFlowBundle\Entity\DealStage;
use MauticPlugin\DealFlowBundle\Entity\DealStageRepository;
use MauticPlugin\DealFlowBundle\Entity\DealTask;
use MauticPlugin\DealFlowBundle\Event\DealEvent;
use MauticPlugin\DealFlowBundle\Event\DealStageChangedEvent;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;

class DealManager
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly DefaultPipelineInitializer $pipelineInitializer,
        private readonly DealStageRepository $stageRepository,
        private readonly AttachmentStorage $attachmentStorage,
        private readonly DealFieldManager $dealFieldManager,
        private readonly EventDispatcherInterface $dispatcher,
    ) {
    }

    /**
     * @param array<string, mixed> $data
     */
    public function createDealFromArray(array $data, ?int $actorId = null, string $origin = 'api'): Deal
    {
        $deal = new Deal();

        return $this->updateDealFromArray($deal, $data, $actorId, $origin);
    }

    /**
     * @param array<string, mixed> $data
     */
    public function updateDealFromArray(Deal $deal, array $data, ?int $actorId = null, string $origin = 'api'): Deal
    {
        $isNew = null === $deal->getId();
        $previousStage = $deal->getStage();
        $previousStatus = $deal->getStatus();
        $previousOwnerId = $deal->getOwnerId();
        $previousCompanyId = $deal->getCompanyId();
        $pipelineId = $data['pipeline_id'] ?? $data['pipelineId'] ?? null;
        $pipeline = null !== $pipelineId && '' !== (string) $pipelineId
            ? $this->pipelineInitializer->resolvePipeline((int) $pipelineId)
            : ($deal->getPipeline() ?? $this->pipelineInitializer->ensureDefaultPipeline());

        $deal->setPipeline($pipeline);

        if (isset($data['title'])) {
            $deal->setTitle(trim((string) $data['title']));
        }

        if (isset($data['company_id'])) {
            $deal->setCompanyId((int) $data['company_id']);
        }

        if (isset($data['owner_id'])) {
            $deal->setOwnerId((int) $data['owner_id']);
        }

        if (array_key_exists('amount', $data)) {
            $deal->setAmount(null !== $data['amount'] && '' !== $data['amount'] ? (string) $data['amount'] : null);
        }

        if (!empty($data['currency'])) {
            $deal->setCurrency((string) $data['currency']);
        }

        if (array_key_exists('confidence', $data)) {
            $deal->setConfidence(null !== $data['confidence'] && '' !== $data['confidence'] ? (int) $data['confidence'] : null);
        }

        if (array_key_exists('source', $data)) {
            $deal->setSource('' !== (string) $data['source'] ? (string) $data['source'] : null);
        }

        if (array_key_exists('external_ref', $data)) {
            $deal->setExternalRef('' !== (string) $data['external_ref'] ? (string) $data['external_ref'] : null);
        }

        if (array_key_exists('description', $data)) {
            $deal->setDescription('' !== (string) $data['description'] ? (string) $data['description'] : null);
        }

        if (array_key_exists('custom_fields', $data)) {
            $customFields = $data['custom_fields'];
            if (null === $customFields) {
                $deal->setCustomFields([]);
            } elseif (!is_array($customFields)) {
                throw new \InvalidArgumentException('custom_fields must be an object of field aliases to values.');
            } else {
                $normalizedCustomFields = $this->dealFieldManager->normalizeSubmittedValues($customFields);
                if ([] !== $normalizedCustomFields['errors']) {
                    throw new \InvalidArgumentException(reset($normalizedCustomFields['errors']) ?: 'Invalid custom field payload.');
                }

                $deal->setCustomFields($this->dealFieldManager->mergeStoredValues($deal->getCustomFields(), $normalizedCustomFields['values']));
            }
        }

        if (array_key_exists('expected_close_date', $data)) {
            $expectedCloseDate = $data['expected_close_date'];
            $deal->setExpectedCloseDate(
                is_string($expectedCloseDate) && '' !== $expectedCloseDate ? new \DateTimeImmutable($expectedCloseDate) : null
            );
        }

        $resolvedStage = $this->resolveStage($data, $pipeline);
        if ($resolvedStage instanceof DealStage) {
            $deal->setStage($resolvedStage)->setPipeline($resolvedStage->getPipeline() ?? $pipeline);
            // Mark the stage FK dirty via DBAL after flush (relationship setters
            // can't use isChanged — see moveToStage for full explanation).
            $stageChanged = true;
        } elseif (!$deal->getStage()) {
            $firstStage = $this->pipelineInitializer->getFirstStage($pipeline);
            if ($firstStage instanceof DealStage) {
                $deal->setStage($firstStage);
            }
        }

        $result = $this->saveDealEntity($deal, $actorId, $origin, $previousStage, $previousOwnerId, $previousCompanyId, $previousStatus);

        // Ensure the stage FK is written via DBAL — setStage() on a relationship
        // can't use isChanged(), so Doctrine may skip the UPDATE for the FK column.
        if (isset($stageChanged) && $deal->getStage() && $deal->getId()) {
            $this->entityManager->getConnection()->update(
                MAUTIC_TABLE_PREFIX.'dealflow_deals',
                ['stage_id' => $deal->getStage()->getId()],
                ['id' => $deal->getId()]
            );
        }

        return $result;
    }

    public function moveToStage(Deal $deal, DealStage $stage, ?int $actorId = null, string $origin = 'ui'): Deal
    {
        $previousStage = $deal->getStage();

        // Use DBAL to write the stage FK directly — relationship setters can't use
        // isChanged() (entity objects crash the string cast in CommonEntity), so
        // Doctrine's UnitOfWork silently skips the UPDATE without a raw write.
        $deal->setStage($stage);
        $deal->setDateModified(new \DateTime());
        $deal->setModifiedBy($actorId);

        $dealId = $deal->getId();

        $stageType = $stage->getStageType();
        $newStatus = 'open' === $stageType ? 'open' : $stageType;

        $this->entityManager->getConnection()->update(
            MAUTIC_TABLE_PREFIX.'dealflow_deals',
            [
                'stage_id' => $stage->getId(),
                'status' => $newStatus,
                'date_modified' => (new \DateTime())->format('Y-m-d H:i:s'),
                'modified_by' => $actorId,
            ],
            ['id' => $dealId]
        );

        // Clear ALL managed entities so Doctrine cannot flush stale state back.
        // setStage() can't use isChanged() (entity objects crash the string cast),
        // so the stage FK is never in Doctrine's changeset. Any flush after the
        // DBAL write would overwrite it. clear() is the only safe way to prevent this.
        $this->entityManager->clear();

        // Re-fetch a clean copy from the DB (identity map is empty after clear)
        $deal = $this->entityManager->find(Deal::class, $dealId);

        // Fire stage changed event with the correct previous stage
        if (($previousStage?->getId() ?? 0) !== ($stage->getId() ?? 0)) {
            $payload = [
                'previousStage' => $previousStage?->getName(),
                'currentStage' => $stage->getName(),
            ];
            $this->recordAudit($deal, DealFlowEvents::DEAL_STAGE_CHANGED, $origin, 'user', $actorId, $payload);
            $this->dispatcher->dispatch(
                new DealStageChangedEvent($deal, $previousStage, $stage, $origin, 'user', $actorId, $payload),
                DealFlowEvents::DEAL_STAGE_CHANGED
            );
        }

        $this->recordAudit($deal, DealFlowEvents::DEAL_UPDATED, $origin, 'user', $actorId, ['changes' => ['stage' => [$previousStage?->getName(), $stage->getName()]]]);
        $this->entityManager->flush();
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_UPDATED);

        return $deal;
    }

    public function saveDealEntity(
        Deal $deal,
        ?int $actorId = null,
        string $origin = 'ui',
        ?DealStage $previousStage = null,
        ?int $previousOwnerId = null,
        ?int $previousCompanyId = null,
        ?string $previousStatus = null,
    ): Deal {
        $isNew = null === $deal->getId();
        $previousStage ??= $deal->getStage();
        $previousOwnerId ??= $deal->getOwnerId();
        $previousCompanyId ??= $deal->getCompanyId();
        $previousStatus ??= $deal->getStatus();

        if (!$deal->getPipeline()) {
            $deal->setPipeline($this->pipelineInitializer->ensureDefaultPipeline());
        }

        if (!$deal->getStage() && $deal->getPipeline()) {
            $stage = $this->pipelineInitializer->getFirstStage($deal->getPipeline());
            if ($stage instanceof DealStage) {
                $deal->setStage($stage);
            }
        }

        if ($isNew) {
            $deal->setDateAdded(new \DateTime())
                ->setCreatedBy($actorId);
        }

        $deal->setDateModified(new \DateTime())
            ->setModifiedBy($actorId);

        $this->syncStatus($deal);

        $this->entityManager->persist($deal);

        // Collect audit records and pending events before the flush.
        // Recording happens here so getChanges() reflects pre-flush state.
        $eventName = $isNew ? DealFlowEvents::DEAL_CREATED : DealFlowEvents::DEAL_UPDATED;
        $changes = $deal->getChanges(true);

        /** @var list<array{0: object, 1: string}> $pendingEvents */
        $pendingEvents = [];

        $this->recordAudit($deal, $eventName, $origin, 'user', $actorId, ['changes' => $changes]);
        $pendingEvents[] = [new DealEvent($deal, $origin, 'user', $actorId), $eventName];

        if (($previousStage?->getId() ?? 0) !== ($deal->getStage()?->getId() ?? 0) && $deal->getStage() instanceof DealStage) {
            $stagePayload = [
                'previousStage' => $previousStage?->getName(),
                'currentStage' => $deal->getStage()->getName(),
            ];
            $this->recordAudit($deal, DealFlowEvents::DEAL_STAGE_CHANGED, $origin, 'user', $actorId, $stagePayload);
            $pendingEvents[] = [
                new DealStageChangedEvent($deal, $previousStage, $deal->getStage(), $origin, 'user', $actorId, $stagePayload),
                DealFlowEvents::DEAL_STAGE_CHANGED,
            ];
        }

        if ($previousOwnerId !== $deal->getOwnerId()) {
            $ownerEvent = null === $previousOwnerId ? DealFlowEvents::DEAL_OWNER_ASSIGNED : DealFlowEvents::DEAL_OWNER_REASSIGNED;
            $this->recordAudit($deal, $ownerEvent, $origin, 'user', $actorId, ['ownerId' => $deal->getOwnerId()]);
            $pendingEvents[] = [new DealEvent($deal, $origin, 'user', $actorId), $ownerEvent];
        }

        if ($previousCompanyId !== $deal->getCompanyId()) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_COMPANY_CHANGED, $origin, 'user', $actorId, ['companyId' => $deal->getCompanyId()]);
            $pendingEvents[] = [new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_COMPANY_CHANGED];
        }

        if ($previousStatus !== $deal->getStatus()) {
            if ('won' === $deal->getStatus()) {
                $this->recordAudit($deal, DealFlowEvents::DEAL_CLOSED_WON, $origin, 'user', $actorId, []);
                $pendingEvents[] = [new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_CLOSED_WON];
            }

            if ('lost' === $deal->getStatus()) {
                $this->recordAudit($deal, DealFlowEvents::DEAL_CLOSED_LOST, $origin, 'user', $actorId, []);
                $pendingEvents[] = [new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_CLOSED_LOST];
            }
        }

        // Single flush: persists the deal entity and all audit records together.
        $this->entityManager->flush();

        // Dispatch events only after successful persistence.
        foreach ($pendingEvents as [$event, $eventName]) {
            $this->dispatcher->dispatch($event, $eventName);
        }

        return $deal;
    }

    public function attachContact(Deal $deal, int $contactId, ?string $label = null, ?int $actorId = null, string $origin = 'ui'): DealContactAssociation
    {
        if ($contactId <= 0) {
            throw new \InvalidArgumentException('contactId must be a positive integer.');
        }

        foreach ($deal->getContactAssociations() as $existingAssociation) {
            if ($existingAssociation->getContactId() === $contactId) {
                $existingAssociation->setLabel($label);
                $deal->setDateModified(new \DateTime());
                $deal->setModifiedBy($actorId);
                $this->entityManager->persist($deal);
                $this->entityManager->flush();

                return $existingAssociation;
            }
        }

        $association = new DealContactAssociation();
        $association->setContactId($contactId)
            ->setLabel($label)
            ->setCreatedBy($actorId);
        $deal->addContactAssociation($association);

        $deal->setDateModified(new \DateTime());
        $deal->setModifiedBy($actorId);
        $this->entityManager->persist($deal);
        $this->entityManager->persist($association);

        $this->recordAudit($deal, DealFlowEvents::DEAL_CONTACT_ATTACHED, $origin, 'user', $actorId, ['contactId' => $contactId, 'label' => $label]);
        $this->entityManager->flush();
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_CONTACT_ATTACHED);

        return $association;
    }

    public function detachContact(Deal $deal, int $contactId, ?int $actorId = null, string $origin = 'ui'): bool
    {
        $association = null;
        foreach ($deal->getContactAssociations() as $assoc) {
            if ($assoc->getContactId() === $contactId) {
                $association = $assoc;
                break;
            }
        }

        if (!$association) {
            return false;
        }

        $label = $association->getLabel();
        $this->entityManager->remove($association);

        $deal->setDateModified(new \DateTime());
        $deal->setModifiedBy($actorId);

        $this->recordAudit($deal, DealFlowEvents::DEAL_CONTACT_DETACHED, $origin, 'user', $actorId, [
            'contact_id' => $contactId,
            'label' => $label,
        ]);
        $this->entityManager->flush();
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_CONTACT_DETACHED);

        return true;
    }

    public function updateField(Deal $deal, string $field, mixed $value, ?int $actorId = null, string $origin = 'ui'): void
    {
        $oldValue = null;

        // Update dateModified for all inline field edits
        $deal->setDateModified(new \DateTime());
        $deal->setModifiedBy($actorId);

        // DEFERRED_EXPLICIT: must persist() before flush() or Doctrine skips changeset computation
        $this->entityManager->persist($deal);

        switch ($field) {
            case 'confidence':
                $oldValue = $deal->getConfidence();
                $deal->setConfidence($value === '' || null === $value ? null : (int) $value);
                break;

            case 'source':
                $oldValue = $deal->getSource();
                $deal->setSource($value === '' || null === $value ? null : (string) $value);
                break;

            case 'amount':
                $oldValue = $deal->getAmount();
                $deal->setAmount($value === '' || null === $value ? null : (string) $value);
                break;

            case 'currency':
                $oldValue = $deal->getCurrency();
                $deal->setCurrency((string) $value);
                break;

            case 'owner_id':
                $oldValue = $deal->getOwnerId();
                $deal->setOwnerId($value === '' || null === $value ? null : (int) $value);
                break;

            case 'company_id':
                $oldValue = $deal->getCompanyId();
                $deal->setCompanyId($value === '' || null === $value ? null : (int) $value);
                break;

            case 'expected_close':
                // DateTime setter doesn't use isChanged — must use DBAL
                $oldValue = $deal->getExpectedCloseDate()?->format('Y-m-d');
                $newDate = ($value === '' || null === $value) ? null : $value;

                // Record audit BEFORE clear() detaches all entities
                $this->recordAudit($deal, DealFlowEvents::DEAL_FIELD_UPDATED, $origin, 'user', $actorId, [
                    'field' => $field,
                    'old_value' => $oldValue,
                    'new_value' => $value,
                ]);
                $this->entityManager->flush();

                $conn = $this->entityManager->getConnection();
                $conn->update(MAUTIC_TABLE_PREFIX.'dealflow_deals', [
                    'expected_close_date' => $newDate,
                    'date_modified' => (new \DateTime())->format('Y-m-d H:i:s'),
                    'modified_by' => $actorId,
                ], ['id' => $deal->getId()]);
                $this->entityManager->clear();

                return; // Audit already recorded above

            default:
                throw new \InvalidArgumentException(sprintf("Field '%s' is not editable.", $field));
        }

        $this->recordAudit($deal, DealFlowEvents::DEAL_FIELD_UPDATED, $origin, 'user', $actorId, [
            'field' => $field,
            'old_value' => $oldValue,
            'new_value' => $value,
        ]);
        $this->entityManager->flush();
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId), DealFlowEvents::DEAL_UPDATED);
    }

    /**
     * @param list<array{contact_id?: int|null, label?: string|null}> $contacts
     */
    public function syncContactAssociations(Deal $deal, array $contacts, ?int $actorId = null, string $origin = 'ui'): void
    {
        $this->entityManager->wrapInTransaction(function () use ($deal, $contacts, $actorId, $origin): void {
            $normalizedContacts = [];

            foreach ($contacts as $contact) {
                $contactId = isset($contact['contact_id']) ? (int) $contact['contact_id'] : 0;
                if ($contactId <= 0) {
                    continue;
                }

                $label = isset($contact['label']) ? trim((string) $contact['label']) : '';
                $normalizedContacts[$contactId] = '' !== $label ? $label : null;
            }

            $existingAssociations = [];

            foreach ($deal->getContactAssociations() as $association) {
                $existingAssociations[$association->getContactId()] = $association;
            }

            foreach ($normalizedContacts as $contactId => $label) {
                if (isset($existingAssociations[$contactId])) {
                    $existingAssociations[$contactId]->setLabel($label);
                    unset($existingAssociations[$contactId]);

                    continue;
                }

                $association = new DealContactAssociation();
                $association->setContactId($contactId)
                    ->setLabel($label)
                    ->setCreatedBy($actorId);
                $deal->addContactAssociation($association);

                $this->entityManager->persist($association);
                $this->recordAudit($deal, DealFlowEvents::DEAL_CONTACT_ATTACHED, $origin, 'user', $actorId, ['contactId' => $contactId, 'label' => $label]);
            }

            foreach ($existingAssociations as $association) {
                $deal->removeContactAssociation($association);
                $this->entityManager->remove($association);
            }

            if ([] !== $existingAssociations || [] !== $normalizedContacts) {
                $deal->setDateModified(new \DateTime())
                    ->setModifiedBy($actorId);
                $this->entityManager->persist($deal);
            }

            $this->entityManager->flush();
        });
    }

    public function addNote(Deal $deal, string $body, ?int $actorId = null, string $origin = 'ui', string $sourceType = 'manual', ?string $sourceRef = null): DealNote
    {
        $note = new DealNote();
        $note->setDeal($deal)
            ->setBody($body)
            ->setSourceType($sourceType)
            ->setSourceRef($sourceRef)
            ->setAuthorUserId($actorId);

        $this->entityManager->persist($note);

        // Audit is persisted before flush; noteId will be set by DB after flush.
        // The audit entity references the note via ORM relationship (correct FK),
        // and sourceType provides context without needing the numeric ID.
        $payload = ['sourceType' => $sourceType];
        $this->recordAudit($deal, DealFlowEvents::DEAL_NOTE_ADDED, $origin, 'user', $actorId, $payload);

        // Single flush: persists note and audit record together.
        $this->entityManager->flush();

        // Dispatch only after successful persistence.
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_NOTE_ADDED);

        return $note;
    }

    public function updateNote(DealNote $note, string $body, ?int $actorId = null, string $origin = 'ui'): DealNote
    {
        $note->setBody($body);

        $this->entityManager->getConnection()->update(
            MAUTIC_TABLE_PREFIX.DealNote::TABLE_NAME,
            ['body' => $body],
            ['id' => $note->getId()]
        );
        $this->entityManager->refresh($note);

        $deal = $note->getDeal();
        $payload = ['noteId' => $note->getId()];
        if ($deal instanceof Deal) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_NOTE_UPDATED, $origin, 'user', $actorId, $payload);
            $this->entityManager->flush();
            $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_NOTE_UPDATED);
        }

        return $note;
    }

    public function deleteNote(DealNote $note, ?int $actorId = null, string $origin = 'ui'): void
    {
        $deal = $note->getDeal();
        $payload = ['noteId' => $note->getId()];

        if ($deal instanceof Deal) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_NOTE_DELETED, $origin, 'user', $actorId, $payload);
            $this->entityManager->flush();
        }

        $this->entityManager->getConnection()->delete(
            MAUTIC_TABLE_PREFIX.DealNote::TABLE_NAME,
            ['id' => $note->getId()]
        );

        if ($deal instanceof Deal) {
            $deal->setDateModified(new \DateTime());
            $deal->setModifiedBy($actorId);
            $this->entityManager->flush();
            $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_NOTE_DELETED);
        }
    }

    /**
     * @param array<string, mixed> $data
     */
    public function addTask(Deal $deal, array $data, ?int $actorId = null, string $origin = 'ui'): DealTask
    {
        $task = new DealTask();
        $task->setDeal($deal)
            ->setTitle(trim((string) ($data['title'] ?? 'Task')))
            ->setDescription('' !== (string) ($data['description'] ?? '') ? (string) $data['description'] : null)
            ->setPriority((string) ($data['priority'] ?? DealTask::PRIORITY_NORMAL))
            ->setOwnerId(null !== ($data['owner_id'] ?? null) && '' !== (string) $data['owner_id'] ? (int) $data['owner_id'] : null)
            ->setCreatedBy($actorId);

        if (!empty($data['due_at'])) {
            $task->setDueAt(new \DateTimeImmutable((string) $data['due_at']));
        }

        if (!empty($data['reminder_at'])) {
            $task->setReminderAt(new \DateTimeImmutable((string) $data['reminder_at']));
        }

        $this->entityManager->persist($task);

        // Audit is persisted before flush; taskId will be assigned by DB after flush.
        // The title provides context without needing the numeric ID pre-flush.
        $payload = ['title' => $task->getTitle()];
        $this->recordAudit($deal, DealFlowEvents::DEAL_TASK_ADDED, $origin, 'user', $actorId, $payload);

        // Single flush: persists task and audit record together.
        $this->entityManager->flush();

        // Dispatch only after successful persistence.
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_TASK_ADDED);

        return $task;
    }

    public function completeTask(DealTask $task, ?int $actorId = null, string $origin = 'ui'): DealTask
    {
        $completedAt = new \DateTimeImmutable();

        $task->setStatus('completed')
            ->setUpdatedAt($completedAt);

        // Write the status explicitly so task completion is not dependent on ORM dirty tracking.
        $this->entityManager->getConnection()->update(
            MAUTIC_TABLE_PREFIX.DealTask::TABLE_NAME,
            [
                'status' => $task->getStatus(),
                'updated_at' => $completedAt->format('Y-m-d H:i:s'),
            ],
            ['id' => $task->getId()]
        );
        $this->entityManager->refresh($task);

        $deal = $task->getDeal();
        $payload = ['taskId' => $task->getId(), 'title' => $task->getTitle()];
        if ($deal instanceof Deal) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_TASK_COMPLETED, $origin, 'user', $actorId, $payload);
            $this->entityManager->flush();
            $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_TASK_COMPLETED);
        }

        return $task;
    }

    public function uncompleteTask(DealTask $task, ?int $actorId = null, string $origin = 'ui'): DealTask
    {
        $now = new \DateTimeImmutable();

        $task->setStatus('open')
            ->setUpdatedAt($now);

        $this->entityManager->getConnection()->update(
            MAUTIC_TABLE_PREFIX.DealTask::TABLE_NAME,
            [
                'status' => $task->getStatus(),
                'updated_at' => $now->format('Y-m-d H:i:s'),
            ],
            ['id' => $task->getId()]
        );
        $this->entityManager->refresh($task);

        $deal = $task->getDeal();
        $payload = ['taskId' => $task->getId(), 'title' => $task->getTitle()];
        if ($deal instanceof Deal) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_TASK_UNCOMPLETED, $origin, 'user', $actorId, $payload);
            $this->entityManager->flush();
            $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_TASK_UNCOMPLETED);
        }

        return $task;
    }

    /**
     * @param array<string, mixed> $data
     */
    public function updateTask(DealTask $task, array $data, ?int $actorId = null, string $origin = 'ui'): DealTask
    {
        if (isset($data['title']) && '' !== trim((string) $data['title'])) {
            $task->setTitle(trim((string) $data['title']));
        }

        if (\array_key_exists('description', $data)) {
            $task->setDescription('' !== (string) ($data['description'] ?? '') ? (string) $data['description'] : null);
        }

        if (isset($data['priority'])) {
            $priority = (string) $data['priority'];
            $allowedPriorities = [DealTask::PRIORITY_LOW, DealTask::PRIORITY_NORMAL, DealTask::PRIORITY_HIGH, DealTask::PRIORITY_URGENT];
            if (in_array($priority, $allowedPriorities, true)) {
                $task->setPriority($priority);
            }
        }

        if (\array_key_exists('due_at', $data)) {
            $task->setDueAt(!empty($data['due_at']) ? new \DateTimeImmutable((string) $data['due_at']) : null);
        }

        if (\array_key_exists('owner_id', $data)) {
            $task->setOwnerId(null !== ($data['owner_id'] ?? null) && '' !== (string) $data['owner_id'] ? (int) $data['owner_id'] : null);
        }

        if (isset($data['status'])) {
            $status = (string) $data['status'];
            $allowedStatuses = ['open', 'completed'];
            if (in_array($status, $allowedStatuses, true)) {
                $task->setStatus($status);
            }
        }

        $now = new \DateTimeImmutable();
        $task->setUpdatedAt($now);

        // Build DBAL columns from the task's current state to ensure persistence.
        $columns = [
            'title' => $task->getTitle(),
            'description' => $task->getDescription(),
            'status' => $task->getStatus(),
            'priority' => $task->getPriority(),
            'owner_id' => $task->getOwnerId(),
            'due_at' => null !== $task->getDueAt() ? $task->getDueAt()->format('Y-m-d H:i:s') : null,
            'updated_at' => $now->format('Y-m-d H:i:s'),
        ];

        $this->entityManager->getConnection()->update(
            MAUTIC_TABLE_PREFIX.DealTask::TABLE_NAME,
            $columns,
            ['id' => $task->getId()]
        );
        $this->entityManager->refresh($task);

        $deal = $task->getDeal();
        $payload = ['taskId' => $task->getId(), 'title' => $task->getTitle()];
        if ($deal instanceof Deal) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_TASK_UPDATED, $origin, 'user', $actorId, $payload);
            $this->entityManager->flush();
            $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_TASK_UPDATED);
        }

        return $task;
    }

    public function deleteTask(DealTask $task, ?int $actorId = null, string $origin = 'ui'): void
    {
        $deal = $task->getDeal();
        $payload = ['taskId' => $task->getId(), 'title' => $task->getTitle()];

        if ($deal instanceof Deal) {
            $this->recordAudit($deal, DealFlowEvents::DEAL_TASK_DELETED, $origin, 'user', $actorId, $payload);
            $this->entityManager->flush();
        }

        $this->entityManager->getConnection()->delete(
            MAUTIC_TABLE_PREFIX.DealTask::TABLE_NAME,
            ['id' => $task->getId()]
        );

        if ($deal instanceof Deal) {
            $deal->setDateModified(new \DateTime());
            $deal->setModifiedBy($actorId);
            $this->entityManager->flush();
            $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_TASK_DELETED);
        }
    }

    public function addAttachment(Deal $deal, UploadedFile $file, ?int $actorId = null, string $origin = 'ui'): DealAttachment
    {
        $stored = $this->attachmentStorage->store($file);
        $attachment = new DealAttachment();
        $attachment->setDeal($deal)
            ->setStoragePath($stored['storagePath'])
            ->setMimeType($stored['mimeType'])
            ->setSizeBytes($stored['sizeBytes'])
            ->setOriginalFilename($stored['originalFilename'])
            ->setUploadedBy($actorId);

        $this->entityManager->persist($attachment);

        // Audit is persisted before flush; filename provides context without attachmentId pre-flush.
        $payload = ['filename' => $attachment->getOriginalFilename()];
        $this->recordAudit($deal, DealFlowEvents::DEAL_ATTACHMENT_ADDED, $origin, 'user', $actorId, $payload);

        // Single flush: persists attachment and audit record together.
        $this->entityManager->flush();

        // Dispatch only after successful persistence.
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_ATTACHMENT_ADDED);

        return $attachment;
    }

    public function addAttachmentFromPath(Deal $deal, string $sourcePath, string $originalFilename, ?int $actorId = null, string $origin = 'api'): DealAttachment
    {
        $stored = $this->attachmentStorage->storeFromPath($sourcePath, $originalFilename);
        $attachment = new DealAttachment();
        $attachment->setDeal($deal)
            ->setStoragePath($stored['storagePath'])
            ->setMimeType($stored['mimeType'])
            ->setSizeBytes($stored['sizeBytes'])
            ->setOriginalFilename($stored['originalFilename'])
            ->setUploadedBy($actorId);

        $this->entityManager->persist($attachment);

        $payload = ['filename' => $attachment->getOriginalFilename()];
        $this->recordAudit($deal, DealFlowEvents::DEAL_ATTACHMENT_ADDED, $origin, 'user', $actorId, $payload);

        $this->entityManager->flush();

        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_ATTACHMENT_ADDED);

        return $attachment;
    }

    /**
     * Archive deals by setting isPublished to false. Returns count of archived deals.
     *
     * @param list<Deal> $deals
     */
    public function archiveDeals(array $deals, ?int $actorId = null, string $origin = 'ui'): int
    {
        $count = 0;

        foreach ($deals as $deal) {
            if (!$deal->isPublished()) {
                continue;
            }

            $deal->setIsPublished(false);
            $deal->setDateModified(new \DateTime());
            $deal->setModifiedBy($actorId);
            $this->entityManager->persist($deal);
            $this->recordAudit($deal, DealFlowEvents::DEAL_UPDATED, $origin, 'user', $actorId, ['action' => 'archived']);
            ++$count;
        }

        if ($count > 0) {
            $this->entityManager->flush();
        }

        return $count;
    }

    /**
     * Unarchive deals by setting isPublished to true. Returns count of restored deals.
     *
     * @param list<Deal> $deals
     */
    public function unarchiveDeals(array $deals, ?int $actorId = null, string $origin = 'ui'): int
    {
        $count = 0;

        foreach ($deals as $deal) {
            if ($deal->isPublished()) {
                continue;
            }

            $deal->setIsPublished(true);
            $deal->setDateModified(new \DateTime());
            $deal->setModifiedBy($actorId);
            $this->entityManager->persist($deal);
            $this->recordAudit($deal, DealFlowEvents::DEAL_UPDATED, $origin, 'user', $actorId, ['action' => 'unarchived']);
            ++$count;
        }

        if ($count > 0) {
            $this->entityManager->flush();
        }

        return $count;
    }

    /**
     * Move multiple deals to a stage. Returns count of moved deals.
     *
     * @param list<Deal> $deals
     */
    public function batchChangeStage(array $deals, DealStage $stage, ?int $actorId = null, string $origin = 'ui'): int
    {
        $count = 0;

        foreach ($deals as $deal) {
            if ($deal->getStage()?->getId() === $stage->getId()) {
                continue;
            }

            if ($stage->getPipeline()?->getId() !== $deal->getPipeline()?->getId()) {
                continue;
            }

            $this->moveToStage($deal, $stage, $actorId, $origin);
            ++$count;
        }

        return $count;
    }

    public function deleteAttachment(DealAttachment $attachment, ?int $actorId = null, string $origin = 'ui'): void
    {
        $deal = $attachment->getDeal();
        if (!$deal instanceof Deal) {
            return;
        }

        $payload = ['attachmentId' => $attachment->getId(), 'filename' => $attachment->getOriginalFilename()];

        $this->attachmentStorage->delete($attachment->getStoragePath());
        $this->entityManager->remove($attachment);
        $this->recordAudit($deal, DealFlowEvents::DEAL_ATTACHMENT_DELETED, $origin, 'user', $actorId, $payload);
        $this->entityManager->flush();
        $this->dispatcher->dispatch(new DealEvent($deal, $origin, 'user', $actorId, $payload), DealFlowEvents::DEAL_ATTACHMENT_DELETED);
    }

    private function syncStatus(Deal $deal): void
    {
        $stage = $deal->getStage();
        if (!$stage instanceof DealStage) {
            $deal->setStatus('open');

            return;
        }

        $status = match ($stage->getStageType()) {
            'won' => 'won',
            'lost' => 'lost',
            default => 'open',
        };

        $deal->setStatus($status);
    }

    /**
     * @param array<string, mixed> $data
     */
    private function resolveStage(array $data, \MauticPlugin\DealFlowBundle\Entity\Pipeline $pipeline): ?DealStage
    {
        $stageId = $data['stage_id'] ?? $data['stageId'] ?? null;
        if (null !== $stageId && '' !== (string) $stageId) {
            $stage = $this->stageRepository->find((int) $stageId);
            if ($stage instanceof DealStage && $stage->getPipeline()?->getId() === $pipeline->getId()) {
                return $stage;
            }
        }

        $stageSlug = $data['stage_slug'] ?? $data['stageSlug'] ?? null;
        if (is_string($stageSlug) && '' !== $stageSlug) {
            return $this->stageRepository->findOneByPipelineAndSlug($pipeline, $stageSlug);
        }

        return null;
    }

    /**
     * @param array<string, mixed> $payload
     */
    private function recordAudit(Deal $deal, string $eventType, string $origin, ?string $actorType, ?int $actorId, array $payload): void
    {
        $audit = new DealAuditEvent();
        $audit->setDeal($deal)
            ->setEventType($eventType)
            ->setOrigin($origin)
            ->setActorType($actorType)
            ->setActorId($actorId)
            ->setPayload($payload);

        $this->entityManager->persist($audit);
    }
}
