<?php

declare(strict_types=1);

namespace FecaMailshots\Application;

use FecaMailshots\Repository\LastRunRepository;
use FecaMailshots\Repository\AttachmentRepository;
use FecaMailshots\Repository\MailshotQueryRepository;
use FecaMailshots\Repository\MailshotRepository;

final class MailshotRunService
{
    private MailshotRepository $mailshots;
    private MailshotQueryRepository $queries;
    private AttachmentRepository $attachments;
    private DataSourceService $dataSources;
    private TemplateRenderer $renderer;
    private SmtpSender $smtp;
    private ImapAppender $imap;
    private MailCredentialsProvider $credentials;
    private LastRunRepository $lastRun;

    public function __construct(
        MailshotRepository $mailshots,
        MailshotQueryRepository $queries,
        AttachmentRepository $attachments,
        DataSourceService $dataSources,
        TemplateRenderer $renderer,
        SmtpSender $smtp,
        ImapAppender $imap,
        MailCredentialsProvider $credentials,
        LastRunRepository $lastRun
    ) {
        $this->mailshots = $mailshots;
        $this->queries = $queries;
        $this->attachments = $attachments;
        $this->dataSources = $dataSources;
        $this->renderer = $renderer;
        $this->smtp = $smtp;
        $this->imap = $imap;
        $this->credentials = $credentials;
        $this->lastRun = $lastRun;
    }

    /** @return array<string, mixed> */
    public function defaultTestEmail(): array
    {
        $raw = getenv('MAILSHOT_TEST_TO_DEFAULT');
        return ['default_test_email' => is_string($raw) ? trim($raw) : ''];
    }

    /** @return array<string,mixed> */
    public function previewRecipients(int $mailshotId, int $limit = 100): array
    {
        try {
            [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()], 'rows' => []];
        }

        $limit = max(1, min(500, $limit));
        $rows = array_slice($rows, 0, $limit);
        $out = [];
        $recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));
        foreach (array_values($rows) as $i => $row) {
            [$keyField, $keyValue] = $this->detectRecipientKey($row, $i);
            $out[] = [
                'index' => $i,
                'recipient_key_field' => $keyField,
                'recipient_key' => $keyValue,
                'recipient_email' => $this->detectEmail($row, $recipientEmailField),
                'row' => $row,
            ];
        }

        return ['ok' => true, 'rows' => $out, 'count' => count($out)];
    }

    /** @return array{ok:bool,count?:int,errors?:list<string>} */
    public function recipientCount(int $mailshotId): array
    {
        try {
            $mailshot = $this->mailshots->find($mailshotId);
            if ($mailshot === null) {
                throw new \RuntimeException('Mailshot not found.');
            }

            $dataSourceName = trim((string) ($mailshot['DataSource'] ?? ''));
            $query = $this->queries->findByName($dataSourceName);
            if ($query === null) {
                throw new \RuntimeException('Mailshot data source is missing.');
            }

            $dsl = trim((string) ($query['dsl_text'] ?? ''));
            if ($dsl === '') {
                throw new \RuntimeException('Data source DSL is empty.');
            }

            $preview = $this->dataSources->preview($dsl, 1);
            if (($preview['errors'] ?? []) !== []) {
                throw new \RuntimeException('Data source preview failed: ' . implode('; ', (array) $preview['errors']));
            }

            return ['ok' => true, 'count' => (int) ($preview['count'] ?? 0)];
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }
    }

    /** @return array<string, mixed> */
    public function renderTest(int $mailshotId, int $recipientIndex): array
    {
        try {
            [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
            if (!isset($rows[$recipientIndex])) {
                return ['ok' => false, 'errors' => ['Selected recipient row was not found.']];
            }

            $rendered = $this->renderer->render(
                (string) $mailshot['Subject'],
                (string) $mailshot['Message'],
                (string) ($mailshot['PDFAttachment'] ?? ''),
                (array) $rows[$recipientIndex]
            );
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Template render failed: ' . $e->getMessage()]];
        }

        return ['ok' => true, 'rendered' => $rendered, 'recipient' => $rows[$recipientIndex]];
    }

    /** @return array<string, mixed> */
    public function sendTest(int $mailshotId, int $recipientIndex, string $testEmail): array
    {
        $testEmail = trim($testEmail);
        if ($testEmail === '') {
            return ['ok' => false, 'errors' => ['Test email address is required.']];
        }

        try {
            $creds = $this->credentials->credentials();
            if ($creds === null) {
                return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
            }

            $render = $this->renderTest($mailshotId, $recipientIndex);
            if (($render['ok'] ?? false) !== true) {
                return $render;
            }

            $mailshot = $this->mailshots->find($mailshotId);
            if ($mailshot === null) {
                return ['ok' => false, 'errors' => ['Mailshot not found.']];
            }

            $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
            $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
            $attachments = array_merge(
                $this->staticAttachments($mailshot),
                $this->renderedPdfAttachments($mailshot, (array) ($render['recipient'] ?? []), (array) ($render['rendered'] ?? []))
            );

            $attemptId = 'test_' . $mailshotId . '_' . $recipientIndex . '_' . gmdate('YmdHis');

            $send = $this->smtp->send(
                $creds,
                [$testEmail],
                $cc,
                $bcc,
                (string) $render['rendered']['subject'],
                (string) $render['rendered']['message'],
                (string) ($mailshot['ReplyTo'] ?? ''),
                $attachments
            );
            $warnings = [];
            try {
                $this->imap->appendSent($creds, (string) $send['raw_mime'], $attemptId);
            } catch (\Throwable $e) {
                $warnings[] = 'IMAP sent-copy failed: ' . $e->getMessage();
            }

            return [
                'ok' => true,
                'warnings' => $warnings,
                'sent_to' => $testEmail,
                'sent_at' => gmdate('c'),
            ];
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
        }
    }

    /** @return array<string, mixed> */
    public function sendTestAll(int $mailshotId, string $testEmail): array
    {
        $testEmail = trim($testEmail);
        if ($testEmail === '') {
            return ['ok' => false, 'errors' => ['Test email address is required.']];
        }

        try {
            $creds = $this->credentials->credentials();
            if ($creds === null) {
                return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
            }

            [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
            if ($rows === []) {
                return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
            }

            $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
            $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
            $counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0];
            $errors = [];

            foreach (array_values($rows) as $index => $row) {
                $counters['attempted']++;
                [$recipientKeyField, $recipientKey] = $this->detectRecipientKey((array) $row, $index);
                try {
                    $render = $this->renderer->render(
                        (string) $mailshot['Subject'],
                        (string) $mailshot['Message'],
                        (string) ($mailshot['PDFAttachment'] ?? ''),
                        (array) $row
                    );
                    $attachments = array_merge(
                        $this->staticAttachments($mailshot),
                        $this->renderedPdfAttachments($mailshot, (array) $row, $render)
                    );
                    $send = $this->smtp->send(
                        $creds,
                        [$testEmail],
                        $cc,
                        $bcc,
                        (string) $render['subject'],
                        (string) $render['message'],
                        (string) ($mailshot['ReplyTo'] ?? ''),
                        $attachments
                    );
                    $counters['sent']++;
                    try {
                        $attemptId = sprintf('test_all_%d_%s_%d', $mailshotId, $recipientKey, time());
                        $this->imap->appendSent($creds, (string) $send['raw_mime'], $attemptId);
                    } catch (\Throwable $imapErr) {
                        $counters['warnings']++;
                    }
                } catch (\Throwable $e) {
                    $counters['failed']++;
                    $errors[] = 'Recipient ' . $recipientKeyField . '=' . $recipientKey . ': ' . $e->getMessage();
                }
            }

            return [
                'ok' => $counters['failed'] === 0,
                'sent_to' => $testEmail,
                'sent_at' => gmdate('c'),
            ] + $counters + ['errors' => $errors];
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Send test failed: ' . $e->getMessage()]];
        }
    }

    /** @return array<string, mixed> */
    public function runMailshot(int $mailshotId): array
    {
        try {
            return $this->runMailshotUnsafe($mailshotId);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Run mailshot failed: ' . $e->getMessage()]];
        }
    }

    /** @return array<string, mixed> */
    private function runMailshotUnsafe(int $mailshotId): array
    {
        $mailshot = $this->mailshots->find($mailshotId);
        if ($mailshot === null) {
            return ['ok' => false, 'errors' => ['Mailshot not found.']];
        }

        $creds = $this->credentials->credentials();
        if ($creds === null) {
            return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
        }

        [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        if ($rows === []) {
            return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
        }

        $this->lastRun->clearForMailshot($mailshotId);
        return $this->executeSendLoop($mailshotId, $mailshot, $rows, $creds, true);
    }

    /** @param list<string> $selectedRecipientKeys @return array<string, mixed> */
    public function runMailshotSelected(int $mailshotId, array $selectedRecipientKeys): array
    {
        try {
            return $this->runMailshotSelectedUnsafe($mailshotId, $selectedRecipientKeys);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Run selected mailshot failed: ' . $e->getMessage()]];
        }
    }

    /** @param list<string> $selectedRecipientKeys @return array<string, mixed> */
    private function runMailshotSelectedUnsafe(int $mailshotId, array $selectedRecipientKeys): array
    {
        $selectedRecipientKeys = array_values(array_filter(array_map(static fn($v): string => trim((string) $v), $selectedRecipientKeys), static fn(string $v): bool => $v !== ''));
        if ($selectedRecipientKeys === []) {
            return ['ok' => false, 'errors' => ['No selected recipient rows were provided.']];
        }

        $creds = $this->credentials->credentials();
        if ($creds === null) {
            return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
        }

        [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        if ($rows === []) {
            return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
        }

        $selectedSet = array_fill_keys($selectedRecipientKeys, true);
        $filteredRows = [];
        foreach (array_values($rows) as $index => $row) {
            $rowIndexKey = 'row_index:' . $index;
            if (isset($selectedSet[$rowIndexKey])) {
                $filteredRows[] = $row;
            }
        }

        if ($filteredRows === []) {
            return ['ok' => false, 'errors' => ['None of the selected recipient rows exist in the current query result.']];
        }

        $this->lastRun->clearForMailshot($mailshotId);
        $result = $this->executeSendLoop($mailshotId, $mailshot, $filteredRows, $creds, true);
        $result['selected_count'] = count($filteredRows);
        return $result;
    }

    /** @return array<string, mixed> */
    public function retryFailed(int $mailshotId): array
    {
        try {
            return $this->retryFailedUnsafe($mailshotId);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Retry failed sends failed: ' . $e->getMessage()]];
        }
    }

    /** @return array<string, mixed> */
    private function retryFailedUnsafe(int $mailshotId): array
    {
        $creds = $this->credentials->credentials();
        if ($creds === null) {
            return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
        }

        [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        $failed = array_values(array_filter($this->lastRun->listForMailshot($mailshotId), static fn(array $r): bool => (string) ($r['status'] ?? '') === 'failed'));

        if ($failed === []) {
            return ['ok' => true, 'attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'log' => []];
        }

        $retryRows = [];
        foreach ($failed as $f) {
            $keyField = (string) ($f['recipient_key_field'] ?? '');
            $key = (string) ($f['recipient_key'] ?? '');
            $matched = $this->findRecipientByKey($rows, $keyField, $key);
            if ($matched !== null) {
                $retryRows[] = $matched;
            }
        }

        return $this->executeSendLoop($mailshotId, $mailshot, $retryRows, $creds, false);
    }

    /** @return array<string, mixed> */
    public function retryRecipient(int $mailshotId, string $recipientKey): array
    {
        try {
            return $this->retryRecipientUnsafe($mailshotId, $recipientKey);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => ['Retry recipient failed: ' . $e->getMessage()]];
        }
    }

    /** @return array<string, mixed> */
    private function retryRecipientUnsafe(int $mailshotId, string $recipientKey): array
    {
        $recipientKey = trim($recipientKey);
        if ($recipientKey === '') {
            return ['ok' => false, 'errors' => ['recipient_key is required.']];
        }

        $creds = $this->credentials->credentials();
        if ($creds === null) {
            return ['ok' => false, 'errors' => ['Missing mail credentials. Configure FECA Mailshots Profile page first.']];
        }

        [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        $entries = $this->lastRun->listForMailshot($mailshotId);
        $candidate = null;
        foreach ($entries as $e) {
            if ((string) ($e['recipient_key'] ?? '') === $recipientKey) {
                $candidate = $e;
                break;
            }
        }
        if ($candidate === null) {
            return ['ok' => false, 'errors' => ['No last-run row found for recipient_key.']];
        }

        $row = $this->findRecipientByKey($rows, (string) ($candidate['recipient_key_field'] ?? ''), $recipientKey);
        if ($row === null) {
            return ['ok' => false, 'errors' => ['Recipient not found in current data source result.']];
        }

        return $this->executeSendLoop($mailshotId, $mailshot, [$row], $creds, false);
    }

    /** @return array<string,mixed> */
    public function generatePdfBatch(int $mailshotId, bool $includeMerged = true, bool $includeFiles = true): array
    {
        if (!$includeMerged && !$includeFiles) {
            return ['ok' => false, 'errors' => ['PDF generation requested no outputs.']];
        }

        try {
            [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }

        if ($rows === []) {
            return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
        }

        $files = [];
        $ghostscriptBinary = $this->findExecutableBinary('gs');
        if ($includeMerged && $ghostscriptBinary === '') {
            return ['ok' => false, 'errors' => ['Merged PDF generation requires Ghostscript (gs) to be installed and available on PATH.']];
        }
        if ($includeMerged && !function_exists('exec')) {
            return ['ok' => false, 'errors' => ['Merged PDF generation requires PHP exec() to be enabled.']];
        }
        $useGhostscriptMerge = $includeMerged;
        $mergedPdfTmpDir = '';
        $mergedPdfPartPaths = [];
        $nameCounts = [];
        $errors = [];
        $skipped = 0;
        $mergedSectionCount = 0;

        if ($useGhostscriptMerge) {
            $mergedPdfTmpDir = sys_get_temp_dir() . '/feca_mailshots_merged_parts_' . str_replace('.', '_', uniqid('', true));
            if (!@mkdir($mergedPdfTmpDir, 0700, true) && !is_dir($mergedPdfTmpDir)) {
                return ['ok' => false, 'errors' => ['Unable to prepare temporary directory for merged PDF build.']];
            }
        }

        foreach (array_values($rows) as $index => $row) {
            try {
                $rendered = $this->renderer->render(
                    (string) $mailshot['Subject'],
                    (string) $mailshot['Message'],
                    (string) ($mailshot['PDFAttachment'] ?? ''),
                    (array) $row
                );
                $pdfHtml = trim((string) ($rendered['pdf_attachment'] ?? ''));
                if ($pdfHtml === '') {
                    $skipped++;
                    continue;
                }

                if ($includeFiles) {
                    $baseName = $this->pdfFilename($mailshot, (array) $row);
                    $filename = $this->uniqueFilename($baseName, $nameCounts);
                    $pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);
                    $files[] = [
                        'filename' => $filename,
                        'content_bytes' => $pdfBytes,
                    ];
                }
                if ($useGhostscriptMerge) {
                    $pdfBytesForMerge = $this->renderPdfBytesFromHtml($pdfHtml);
                    $partPath = $mergedPdfTmpDir . '/part_' . sprintf('%05d', (int) $index) . '.pdf';
                    if (@file_put_contents($partPath, $pdfBytesForMerge) === false) {
                        throw new \RuntimeException('Unable to write temporary merged PDF part.');
                    }
                    $mergedPdfPartPaths[] = $partPath;
                    $mergedSectionCount++;
                    unset($pdfBytesForMerge);
                }
                unset($rendered, $pdfHtml);
            } catch (\Throwable $e) {
                [$keyField, $keyValue] = $this->detectRecipientKey((array) $row, $index);
                $errors[] = 'Recipient ' . $keyField . '=' . $keyValue . ': ' . $e->getMessage();
            }
            if (($index % 10) === 0 && function_exists('gc_collect_cycles')) {
                gc_collect_cycles();
            }
        }

        if ($errors !== []) {
            $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
            return ['ok' => false, 'errors' => $errors];
        }
        if ($includeFiles && $files === []) {
            $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
            return ['ok' => false, 'errors' => ['No PDF attachments were generated from this mailshot.']];
        }
        if ($includeMerged && $mergedSectionCount === 0) {
            $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
            return ['ok' => false, 'errors' => ['No merged PDF content was generated from this mailshot.']];
        }

        $mergedPdfBytes = '';
        if ($includeMerged) {
            try {
                $mergedPdfBytes = $this->mergePdfFilesWithGhostscript($ghostscriptBinary, $mergedPdfPartPaths);
            } catch (\Throwable $e) {
                $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
                return ['ok' => false, 'errors' => ['Merged PDF generation failed: ' . $e->getMessage()]];
            }
            $this->cleanupMergedPartFiles($mergedPdfTmpDir, $mergedPdfPartPaths);
        }

        return [
            'ok' => true,
            'recipient_count' => count($rows),
            'generated_count' => $includeFiles ? count($files) : $mergedSectionCount,
            'skipped_count' => $skipped,
            'files' => $files,
            'merged_pdf_bytes' => $mergedPdfBytes,
        ];
    }

    /** @return array<string,mixed> */
    public function generatePdfZipToTemp(int $mailshotId): array
    {
        $canUseZipArchive = class_exists('ZipArchive');
        $zipBinary = $this->findExecutableBinary('zip');
        if (!$canUseZipArchive && $zipBinary === '') {
            return ['ok' => false, 'errors' => ['Neither ZipArchive extension nor zip CLI binary is available.']];
        }

        try {
            [$mailshot, $rows] = $this->loadMailshotAndRows($mailshotId);
        } catch (\Throwable $e) {
            return ['ok' => false, 'errors' => [$e->getMessage()]];
        }
        if ($rows === []) {
            return ['ok' => false, 'errors' => ['Recipient query returned zero rows.']];
        }

        $tmpZipBase = tempnam(sys_get_temp_dir(), 'feca_mailshots_zip_');
        if (!is_string($tmpZipBase) || $tmpZipBase === '') {
            return ['ok' => false, 'errors' => ['Unable to allocate temporary ZIP file.']];
        }
        @unlink($tmpZipBase);
        $tmpZipPath = $tmpZipBase . '.zip';
        if (is_file($tmpZipPath)) {
            @unlink($tmpZipPath);
        }

        $tmpPdfDir = sys_get_temp_dir() . '/feca_mailshots_zip_' . str_replace('.', '_', uniqid('', true));
        if (!@mkdir($tmpPdfDir, 0700, true) && !is_dir($tmpPdfDir)) {
            @unlink($tmpZipPath);
            return ['ok' => false, 'errors' => ['Unable to open ZIP archive for writing.']];
        }

        $nameCounts = [];
        $errors = [];
        $generated = 0;
        $skipped = 0;
        $generatedPdfNames = [];

        try {
            foreach (array_values($rows) as $index => $row) {
                try {
                    $rendered = $this->renderer->render(
                        (string) $mailshot['Subject'],
                        (string) $mailshot['Message'],
                        (string) ($mailshot['PDFAttachment'] ?? ''),
                        (array) $row
                    );
                    $pdfHtml = trim((string) ($rendered['pdf_attachment'] ?? ''));
                    if ($pdfHtml === '') {
                        $skipped++;
                        continue;
                    }

                    $baseName = $this->pdfFilename($mailshot, (array) $row);
                    $filename = $this->uniqueFilename($baseName, $nameCounts);
                    $pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);
                    if ($pdfBytes === '') {
                        throw new \RuntimeException('Rendered PDF attachment is empty.');
                    }

                    $tmpPdfPath = $tmpPdfDir . '/' . $filename;
                    if (@file_put_contents($tmpPdfPath, $pdfBytes) === false) {
                        throw new \RuntimeException('Unable to write temporary PDF file.');
                    }
                    $generatedPdfNames[] = $filename;
                    unset($pdfBytes);

                    $generated++;
                    unset($rendered, $pdfHtml);
                } catch (\Throwable $e) {
                    [$keyField, $keyValue] = $this->detectRecipientKey((array) $row, $index);
                    $errors[] = 'Recipient ' . $keyField . '=' . $keyValue . ': ' . $e->getMessage();
                }
                if (($index % 5) === 0 && function_exists('gc_collect_cycles')) {
                    gc_collect_cycles();
                }
                if (($index % 5) === 0 && function_exists('gc_mem_caches')) {
                    gc_mem_caches();
                }
            }

            if ($errors !== []) {
                foreach ($generatedPdfNames as $name) {
                    @unlink($tmpPdfDir . '/' . $name);
                }
                @rmdir($tmpPdfDir);
                @unlink($tmpZipPath);
                return ['ok' => false, 'errors' => $errors];
            }
            if ($generated === 0) {
                @rmdir($tmpPdfDir);
                @unlink($tmpZipPath);
                return ['ok' => false, 'errors' => ['No PDF attachments were generated from this mailshot.']];
            }

            if ($canUseZipArchive) {
                $zip = new \ZipArchive();
                $opened = $zip->open($tmpZipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
                if ($opened !== true) {
                    throw new \RuntimeException('Unable to open ZIP archive for writing.');
                }
                foreach ($generatedPdfNames as $name) {
                    if (!$zip->addFile($tmpPdfDir . '/' . $name, $name)) {
                        throw new \RuntimeException('Failed to add ZIP entry "' . $name . '".');
                    }
                }
                $zip->close();
            } else {
                if ($zipBinary === '') {
                    throw new \RuntimeException('zip CLI binary is not available.');
                }
                if (!function_exists('exec')) {
                    throw new \RuntimeException('zip CLI fallback requires exec(), but exec() is unavailable.');
                }
                $cmd = 'cd ' . escapeshellarg($tmpPdfDir)
                    . ' && ' . escapeshellarg($zipBinary)
                    . ' -q -X ' . escapeshellarg($tmpZipPath);
                foreach ($generatedPdfNames as $name) {
                    $cmd .= ' ' . escapeshellarg($name);
                }
                $cmd .= ' 2>&1';
                $output = [];
                $code = 0;
                @exec($cmd, $output, $code);
                if ($code !== 0) {
                    throw new \RuntimeException(
                        'zip CLI failed' . ($output !== [] ? ': ' . trim(implode('; ', $output)) : '.')
                    );
                }
            }

            foreach ($generatedPdfNames as $name) {
                @unlink($tmpPdfDir . '/' . $name);
            }
            @rmdir($tmpPdfDir);

            $size = @filesize($tmpZipPath);
            if (!is_int($size) || $size <= 0) {
                @unlink($tmpZipPath);
                return ['ok' => false, 'errors' => ['Generated ZIP file is empty.']];
            }

            return [
                'ok' => true,
                'recipient_count' => count($rows),
                'generated_count' => $generated,
                'skipped_count' => $skipped,
                'zip_path' => $tmpZipPath,
                'zip_size' => $size,
            ];
        } catch (\Throwable $e) {
            foreach ($generatedPdfNames as $name) {
                @unlink($tmpPdfDir . '/' . $name);
            }
            @rmdir($tmpPdfDir);
            @unlink($tmpZipPath);
            return ['ok' => false, 'errors' => ['ZIP generation failed: ' . $e->getMessage()]];
        }
    }

    private function findExecutableBinary(string $binaryName): string
    {
        $pathEnv = (string) getenv('PATH');
        $candidates = [];
        if ($pathEnv !== '') {
            foreach (explode(':', $pathEnv) as $dir) {
                $dir = trim($dir);
                if ($dir !== '') {
                    $candidates[] = rtrim($dir, '/') . '/' . $binaryName;
                }
            }
        }

        foreach ($candidates as $candidate) {
            if (is_string($candidate) && $candidate !== '' && is_file($candidate) && is_executable($candidate)) {
                return $candidate;
            }
        }
        return '';
    }

    /** @return array{0:array<string,mixed>,1:list<array<string,mixed>>} */
    private function loadMailshotAndRows(int $mailshotId): array
    {
        $mailshot = $this->mailshots->find($mailshotId);
        if ($mailshot === null) {
            throw new \RuntimeException('Mailshot not found.');
        }

        $dataSourceName = trim((string) ($mailshot['DataSource'] ?? ''));
        $query = $this->queries->findByName($dataSourceName);
        if ($query === null) {
            throw new \RuntimeException('Mailshot data source is missing.');
        }

        $dsl = trim((string) ($query['dsl_text'] ?? ''));
        if ($dsl === '') {
            throw new \RuntimeException('Data source DSL is empty.');
        }

        $preview = $this->dataSources->preview($dsl, 5000);
        if (($preview['errors'] ?? []) !== []) {
            throw new \RuntimeException('Data source preview failed: ' . implode('; ', $preview['errors']));
        }

        return [$mailshot, $preview['rows'] ?? []];
    }

    /**
     * @param list<array<string,mixed>> $rows
     * @param array<string,mixed> $mailshot
     * @param array<string,mixed> $creds
     * @return array<string,mixed>
     */
    private function executeSendLoop(int $mailshotId, array $mailshot, array $rows, array $creds, bool $freshRun): array
    {
        $cc = $this->splitAddresses((string) ($mailshot['CC'] ?? ''));
        $bcc = $this->splitAddresses((string) ($mailshot['BCC'] ?? ''));
        $recipientEmailField = trim((string) ($mailshot['RecipientEmailField'] ?? ''));

        $counters = ['attempted' => 0, 'sent' => 0, 'failed' => 0, 'warnings' => 0, 'skipped' => 0];
        $log = [];

        foreach (array_values($rows) as $index => $row) {
            $counters['attempted']++;
            $recipientEmail = $this->detectEmail($row, $recipientEmailField);
            [$recipientKeyField, $recipientKey] = $this->detectRecipientKey($row, $index);

            if ($recipientEmail === null) {
                $counters['failed']++;
                $error = $recipientEmailField !== '' ? ('Recipient email field "' . $recipientEmailField . '" is missing or invalid in recipient row.') : 'No email field found in recipient row.';
                $log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => $error];
                $this->lastRun->create([
                    'mailshot_id' => $mailshotId,
                    'data_source' => (string) ($mailshot['DataSource'] ?? ''),
                    'row_index' => $index,
                    'recipient_key' => $recipientKey,
                    'recipient_key_field' => $recipientKeyField,
                    'recipient_email_last' => null,
                    'status' => 'failed',
                    'error_message' => $error,
                    'attempt_count' => $freshRun ? 1 : 2,
                ]);
                continue;
            }

            try {
                $render = $this->renderer->render(
                    (string) $mailshot['Subject'],
                    (string) $mailshot['Message'],
                    (string) ($mailshot['PDFAttachment'] ?? ''),
                    $row
                );
            } catch (\Throwable $e) {
                $counters['failed']++;
                $log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => 'Template render failed: ' . $e->getMessage()];
                $this->lastRun->create([
                    'mailshot_id' => $mailshotId,
                    'data_source' => (string) ($mailshot['DataSource'] ?? ''),
                    'row_index' => $index,
                    'recipient_key' => $recipientKey,
                    'recipient_key_field' => $recipientKeyField,
                    'recipient_email_last' => $recipientEmail,
                    'status' => 'failed',
                    'error_message' => 'Template render failed: ' . $e->getMessage(),
                    'attempt_count' => $freshRun ? 1 : 2,
                ]);
                continue;
            }

            try {
                $attachments = array_merge(
                    $this->staticAttachments($mailshot),
                    $this->renderedPdfAttachments($mailshot, $row, $render)
                );
                $smtp = $this->smtp->send(
                    $creds,
                    [$recipientEmail],
                    $cc,
                    $bcc,
                    (string) $render['subject'],
                    (string) $render['message'],
                    (string) ($mailshot['ReplyTo'] ?? ''),
                    $attachments
                );

                $warning = null;
                try {
                    $attemptId = sprintf('run_%d_%s_%d', $mailshotId, $recipientKey, time());
                    $this->imap->appendSent($creds, (string) $smtp['raw_mime'], $attemptId);
                } catch (\Throwable $imapErr) {
                    $warning = 'IMAP sent-copy failed: ' . $imapErr->getMessage();
                    $counters['warnings']++;
                }

                $counters['sent']++;
                $log[] = ['recipient_key' => $recipientKey, 'status' => 'sent', 'warning' => $warning];

                $this->lastRun->create([
                    'mailshot_id' => $mailshotId,
                    'data_source' => (string) ($mailshot['DataSource'] ?? ''),
                    'row_index' => $index,
                    'recipient_key' => $recipientKey,
                    'recipient_key_field' => $recipientKeyField,
                    'recipient_email_last' => $recipientEmail,
                    'status' => $warning === null ? 'sent' : 'warning',
                    'warning_message' => $warning,
                    'attempt_count' => $freshRun ? 1 : 2,
                ]);
            } catch (\Throwable $e) {
                $counters['failed']++;
                $log[] = ['recipient_key' => $recipientKey, 'status' => 'failed', 'error' => $e->getMessage()];
                $this->lastRun->create([
                    'mailshot_id' => $mailshotId,
                    'data_source' => (string) ($mailshot['DataSource'] ?? ''),
                    'row_index' => $index,
                    'recipient_key' => $recipientKey,
                    'recipient_key_field' => $recipientKeyField,
                    'recipient_email_last' => $recipientEmail,
                    'status' => 'failed',
                    'error_message' => $e->getMessage(),
                    'attempt_count' => $freshRun ? 1 : 2,
                ]);
            }
        }

        return ['ok' => true] + $counters + ['log' => $log];
    }

    /** @param array<string,mixed> $row @return array{0:string,1:string} */
    private function detectRecipientKey(array $row, int $index): array
    {
        foreach (['ID', 'id', 'Accountid', 'account_id'] as $keyField) {
            if (isset($row[$keyField]) && trim((string) $row[$keyField]) !== '') {
                return [$keyField, trim((string) $row[$keyField])];
            }
        }
        if (($email = $this->detectEmail($row)) !== null) {
            return ['email', $email];
        }
        return ['row_index', (string) $index];
    }

    /** @param list<array<string,mixed>> $rows */
    private function findRecipientByKey(array $rows, string $keyField, string $key): ?array
    {
        foreach ($rows as $r) {
            if ($keyField !== '' && isset($r[$keyField]) && trim((string) $r[$keyField]) === $key) {
                return $r;
            }
            if ($keyField === 'email') {
                $email = $this->detectEmail($r);
                if ($email !== null && $email === $key) {
                    return $r;
                }
            }
        }
        return null;
    }

    /** @param array<string,mixed> $row */
    private function detectEmail(array $row, string $preferredField = ''): ?string
    {
        if ($preferredField !== '') {
            $candidates = [$preferredField];
            if (str_contains($preferredField, '.')) {
                $parts = explode('.', $preferredField);
                $last = (string) end($parts);
                if ($last !== '') {
                    $candidates[] = $last;
                }
            }
            foreach ($candidates as $candidate) {
                foreach ($row as $k => $val) {
                    if (strcasecmp((string) $k, $candidate) === 0) {
                        $v = trim((string) $val);
                        if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) {
                            return $v;
                        }
                    }
                }
            }
            return null;
        }
        foreach (['Email', 'email', 'contact_email', 'recipient_email', 'recipient_email_last'] as $key) {
            if (isset($row[$key])) {
                $v = trim((string) $row[$key]);
                if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) {
                    return $v;
                }
            }
        }
        return null;
    }

    /** @return list<string> */
    private function splitAddresses(string $list): array
    {
        if (trim($list) === '') {
            return [];
        }

        $parts = preg_split('/[;,]+/', $list) ?: [];
        $out = [];
        foreach ($parts as $p) {
            $v = trim((string) $p);
            if ($v !== '' && filter_var($v, FILTER_VALIDATE_EMAIL)) {
                $out[] = $v;
            }
        }
        return array_values(array_unique($out));
    }

    /**
     * @param array<string,mixed> $mailshot
     * @param array<string,mixed> $row
     * @param array<string,mixed> $rendered
     * @return list<array{filename:string,mime_type:string,content_bytes:string}>
     */
    private function renderedPdfAttachments(array $mailshot, array $row, array $rendered): array
    {
        $pdfHtml = trim((string) ($rendered['pdf_attachment'] ?? ''));
        if ($pdfHtml === '') {
            return [];
        }
        $pdfBytes = $this->renderPdfBytesFromHtml($pdfHtml);

        return [[
            'filename' => $this->pdfFilename($mailshot, $row),
            'mime_type' => 'application/pdf',
            'content_bytes' => $pdfBytes,
        ]];
    }

    private function renderPdfBytesFromHtml(string $pdfHtml): string
    {
        if (!class_exists('Dompdf\\Dompdf')) {
            throw new \RuntimeException('Dompdf is not available. Ensure vendor dependencies are installed.');
        }

        $dompdf = $this->createDompdf();
        $dompdf->loadHtml($this->normalizePdfHtmlForDompdf($pdfHtml), 'UTF-8');
        $dompdf->setPaper('A4', 'portrait');
        $dompdf->render();
        $pdfBytes = $dompdf->output();
        if (!is_string($pdfBytes) || $pdfBytes === '') {
            throw new \RuntimeException('Rendered PDF attachment is empty.');
        }
        return $pdfBytes;
    }

    private function renderPdfBytesFromHtmlFile(string $htmlPath): string
    {
        if (!class_exists('Dompdf\\Dompdf')) {
            throw new \RuntimeException('Dompdf is not available. Ensure vendor dependencies are installed.');
        }
        if (!is_file($htmlPath)) {
            throw new \RuntimeException('Merged HTML file not found.');
        }
        $html = @file_get_contents($htmlPath);
        if (!is_string($html) || $html === '') {
            throw new \RuntimeException('Merged HTML file is empty or unreadable.');
        }

        $dompdf = $this->createDompdf();
        $dompdf->loadHtml($this->normalizePdfHtmlForDompdf($html), 'UTF-8');
        $dompdf->setPaper('A4', 'portrait');
        $dompdf->render();
        $pdfBytes = $dompdf->output();
        if (!is_string($pdfBytes) || $pdfBytes === '') {
            throw new \RuntimeException('Rendered merged PDF is empty.');
        }
        return $pdfBytes;
    }

    private function createDompdf(): \Dompdf\Dompdf
    {
        $options = new \Dompdf\Options();
        $options->set('defaultFont', 'DejaVu Sans');
        $options->set('isFontSubsettingEnabled', true);
        return new \Dompdf\Dompdf($options);
    }

    private function normalizePdfHtmlForDompdf(string $html): string
    {
        return strtr($html, [
            "\u{2709}" => $this->dompdfSymbolSpan("\u{2709}"),
            "\u{1F4E7}" => $this->dompdfSymbolSpan("\u{2709}"),
            "\u{260E}" => $this->dompdfSymbolSpan("\u{260E}"),
            "\u{1F4DE}" => $this->dompdfSymbolSpan("\u{260E}"),
        ]);
    }

    private function dompdfSymbolSpan(string $symbol): string
    {
        return '<span style="font-family:DejaVu Sans,sans-serif;">' . $symbol . '</span>';
    }

    /**
     * @param list<string> $pdfPaths
     */
    private function mergePdfFilesWithGhostscript(string $ghostscriptBinary, array $pdfPaths): string
    {
        if ($ghostscriptBinary === '') {
            throw new \RuntimeException('Ghostscript binary is not available.');
        }
        if (!function_exists('exec')) {
            throw new \RuntimeException('Ghostscript merge requires exec(), but exec() is unavailable.');
        }
        if ($pdfPaths === []) {
            throw new \RuntimeException('No PDF parts available for merged output.');
        }

        $tmpBase = tempnam(sys_get_temp_dir(), 'feca_mailshots_merged_pdf_');
        if (!is_string($tmpBase) || $tmpBase === '') {
            throw new \RuntimeException('Unable to allocate temporary merged PDF output file.');
        }
        @unlink($tmpBase);
        $outputPdfPath = $tmpBase . '.pdf';

        $cmd = escapeshellarg($ghostscriptBinary)
            . ' -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite -dCompatibilityLevel=1.4'
            . ' -sOutputFile=' . escapeshellarg($outputPdfPath);
        foreach ($pdfPaths as $path) {
            $cmd .= ' ' . escapeshellarg($path);
        }
        $cmd .= ' 2>&1';

        $output = [];
        $code = 0;
        @exec($cmd, $output, $code);
        if ($code !== 0) {
            @unlink($outputPdfPath);
            throw new \RuntimeException(
                'Ghostscript merge failed' . ($output !== [] ? ': ' . trim(implode('; ', $output)) : '.')
            );
        }

        $mergedBytes = @file_get_contents($outputPdfPath);
        @unlink($outputPdfPath);
        if (!is_string($mergedBytes) || $mergedBytes === '') {
            throw new \RuntimeException('Ghostscript merge produced an empty output file.');
        }

        return $mergedBytes;
    }

    /** @param array<string,mixed> $mailshot @param array<string,mixed> $row */
    private function pdfFilename(array $mailshot, array $row): string
    {
        $base = '';
        $field = trim((string) ($mailshot['PDFFilenameDerivedFrom'] ?? ''));
        if ($field !== '') {
            foreach ($this->rowFieldCandidates($field) as $candidate) {
                foreach ($row as $k => $v) {
                    if (strcasecmp((string) $k, $candidate) !== 0) {
                        continue;
                    }
                    $base = trim((string) $v);
                    if ($base !== '') {
                        break 2;
                    }
                }
            }
        }

        if ($base === '') {
            $base = 'mailshot_attachment_' . gmdate('Ymd_His');
        }
        $base = preg_replace('/[^A-Za-z0-9._-]+/', '_', $base) ?? '';
        $base = trim($base, '._-');
        if ($base === '') {
            $base = 'mailshot_attachment_' . gmdate('Ymd_His');
        }
        if (!str_ends_with(strtolower($base), '.pdf')) {
            $base .= '.pdf';
        }
        return $base;
    }

    /** @return list<string> */
    private function rowFieldCandidates(string $field): array
    {
        $out = [$field];
        if (str_contains($field, '.')) {
            $parts = explode('.', $field);
            $leaf = trim((string) end($parts));
            if ($leaf !== '') {
                $out[] = $leaf;
            }
        }
        return array_values(array_unique(array_filter($out, static fn(string $s): bool => $s !== '')));
    }

    /**
     * @param array<string,int> $nameCounts
     */
    private function uniqueFilename(string $baseName, array &$nameCounts): string
    {
        $key = strtolower($baseName);
        if (!isset($nameCounts[$key])) {
            $nameCounts[$key] = 1;
            return $baseName;
        }

        $nameCounts[$key]++;
        $count = $nameCounts[$key];
        $ext = pathinfo($baseName, PATHINFO_EXTENSION);
        $stem = pathinfo($baseName, PATHINFO_FILENAME);
        if ($stem === '') {
            $stem = 'attachment';
        }
        if ($ext === '') {
            return $stem . '_' . $count;
        }
        return $stem . '_' . $count . '.' . $ext;
    }

    /**
     * @param list<string> $paths
     */
    private function cleanupMergedPartFiles(string $tmpDir, array $paths): void
    {
        foreach ($paths as $p) {
            if (is_string($p) && $p !== '') {
                @unlink($p);
            }
        }
        if ($tmpDir !== '') {
            @rmdir($tmpDir);
        }
    }

    /**
     * @param array<string,mixed> $mailshot
     * @return list<array{filename:string,mime_type:string,content_bytes:string}>
     */
    private function staticAttachments(array $mailshot): array
    {
        $raw = (string) ($mailshot['AttachmentNames'] ?? '[]');
        $names = [];
        $decoded = json_decode($raw, true);
        if (is_array($decoded)) {
            foreach ($decoded as $item) {
                $name = trim((string) $item);
                if ($name !== '') {
                    $names[] = $name;
                }
            }
        }
        $names = array_values(array_unique($names));
        if ($names === []) {
            return [];
        }

        $out = [];
        foreach ($names as $name) {
            $asset = $this->attachments->findByName($name);
            if (!is_array($asset)) {
                throw new \RuntimeException('Attachment "' . $name . '" not found.');
            }
            $fileName = trim((string) ($asset['file_name'] ?? ''));
            $mimeType = trim((string) ($asset['mime_type'] ?? ''));
            if ($mimeType === '') {
                $mimeType = $this->inferMimeTypeFromFilename($fileName);
            }
            $bytes = (string) ($asset['file_bytes'] ?? '');
            if ($fileName === '' || $mimeType === '' || $bytes === '') {
                throw new \RuntimeException(
                    'Attachment "' . $name . '" is invalid: file_name=' . ($fileName === '' ? 'missing' : 'ok')
                    . ', mime_type=' . ($mimeType === '' ? 'missing' : 'ok')
                    . ', file_bytes=' . ($bytes === '' ? 'empty' : ('len ' . strlen($bytes)))
                );
            }
            $out[] = [
                'filename' => $fileName,
                'mime_type' => $mimeType,
                'content_bytes' => $bytes,
            ];
        }
        return $out;
    }

    private function inferMimeTypeFromFilename(string $fileName): string
    {
        $fileName = trim($fileName);
        if ($fileName === '') {
            return '';
        }
        $ext = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
        if ($ext === '') {
            return '';
        }

        $map = [
            'txt' => 'text/plain',
            'csv' => 'text/csv',
            'pdf' => 'application/pdf',
            'doc' => 'application/msword',
            'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'odt' => 'application/vnd.oasis.opendocument.text',
            'rtf' => 'application/rtf',
            'html' => 'text/html',
            'htm' => 'text/html',
            'json' => 'application/json',
            'xml' => 'application/xml',
            'jpg' => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'png' => 'image/png',
            'gif' => 'image/gif',
            'webp' => 'image/webp',
            'svg' => 'image/svg+xml',
            'zip' => 'application/zip',
        ];

        return $map[$ext] ?? '';
    }
}
