<?php
// ============================================================
//  Email sync engine
// ============================================================
//
//  Called by cron/email-sync.php (every 5 minutes) and the
//  manual "Sync now" button on the email portal.
//
//  Strategy:
//    1. For each active account:
//       a. Open INBOX
//       b. Check UIDVALIDITY. If changed, reset our sync state.
//       c. Find UIDs greater than our last_uid_seen.
//          On a brand-new account (last_uid_seen IS NULL), pull
//          ALL message UIDs ever (user picked "Everything").
//       d. Fetch each new message: headers, structure, bodies
//       e. Store metadata + body_text + body_html
//       f. Update last_uid_seen
//
//    2. Evict bodies older than 90 days (keep metadata + snippet)
//
//    3. Update flags (seen/answered/flagged) by re-reading the
//       flag bits for messages synced in last 7 days (cheap)
//
//  Safe to interrupt: a half-finished sync resumes from
//  last_uid_seen on next run.
// ============================================================

require_once __DIR__ . '/imap_client.php';

const EMAIL_BODY_KEEP_DAYS = 90;

/**
 * Sync a single account. Syncs INBOX and the configured Sent folder.
 *
 * @return array{ok: bool, message: string, new_count: int, evicted_count: int}
 */
function email_sync_account(array $acct): array {
    $account_id = (int)$acct['id'];

    // Folders to sync: INBOX always, plus Sent (if configured)
    $folders = ['INBOX'];
    $sent_folder = trim((string)($acct['imap_sent_folder'] ?? ''));
    if ($sent_folder !== '' && strcasecmp($sent_folder, 'INBOX') !== 0) {
        $folders[] = $sent_folder;
    }

    $total_new = 0;
    $errors = [];
    $warnings = [];   // non-fatal — e.g. Sent folder misconfigured
    $inbox_ok = false;

    foreach ($folders as $folder) {
        try {
            $r = email_sync_account_folder($acct, $folder);
            $total_new += $r['new_count'];
            if ($folder === 'INBOX') $inbox_ok = true;
        } catch (Throwable $e) {
            $msg = "{$folder}: " . $e->getMessage();
            if ($folder === 'INBOX') {
                // INBOX failures are real errors — break the whole sync
                $errors[] = $msg;
            } else {
                // Other folders (Sent) — warn but don't fail the account
                $warnings[] = $msg;
            }
            app_log("email sync: account {$account_id} folder {$folder} failed: " . $e->getMessage());
        }
    }

    // Evict bodies older than retention window (covers all folders)
    $evicted = email_sync_evict_old_bodies();

    if ($errors) {
        $msg = mb_substr(implode('; ', $errors), 0, 500);
        db_exec(
            "UPDATE email_accounts SET last_error=:e WHERE id=:id",
            ['e' => $msg, 'id' => $account_id]
        );
        return [
            'ok' => false,
            'message' => $msg,
            'new_count' => $total_new,
            'evicted_count' => $evicted,
        ];
    }

    // INBOX succeeded — mark account healthy even if Sent had issues
    $warning_str = $warnings ? ' (warnings: ' . mb_substr(implode('; ', $warnings), 0, 400) . ')' : '';
    db_exec(
        "UPDATE email_accounts SET last_synced_at=NOW(), last_error=:e WHERE id=:id",
        ['e' => $warnings ? mb_substr(implode('; ', $warnings), 0, 500) : null, 'id' => $account_id]
    );

    return [
        'ok' => true,
        'message' => "Synced {$total_new} new message" . ($total_new === 1 ? '' : 's') . $warning_str,
        'new_count' => $total_new,
        'evicted_count' => $evicted,
    ];
}

/**
 * Sync a single folder on one account. Internal helper used by
 * email_sync_account.
 *
 * @return array{ok: bool, message: string, new_count: int}
 */
function email_sync_account_folder(array $acct, string $folder): array {
    $account_id = (int)$acct['id'];

    imc_require_ext();
    $conn = imc_open($acct, $folder);

    // 1. Get folder status (UIDNEXT, UIDVALIDITY, messages, etc.)
    $status = @imap_status($conn, imc_mailbox_string($acct, $folder), SA_ALL);
    if (!$status) {
        $errs = imap_errors() ?: [];
        imc_close($conn);
        throw new RuntimeException('imap_status failed: ' . implode('; ', $errs));
    }
    $uidvalidity = (int)($status->uidvalidity ?? 0);

    // 2. Load our sync state
    $state = db_row(
        'SELECT * FROM email_sync_state WHERE account_id=:a AND folder=:f',
        ['a' => $account_id, 'f' => $folder]
    );

    $needs_reset = !$state || (int)$state['uidvalidity'] !== $uidvalidity;
    if ($needs_reset) {
        if ($state && (int)$state['uidvalidity'] !== $uidvalidity) {
            db_exec('DELETE FROM email_messages WHERE account_id=:a AND folder=:f',
                ['a' => $account_id, 'f' => $folder]);
        }
        if (!$state) {
            db_insert('email_sync_state', [
                'account_id'  => $account_id,
                'folder'      => $folder,
                'uidvalidity' => $uidvalidity,
                'last_uid_seen' => 0,
            ]);
        } else {
            db_exec(
                'UPDATE email_sync_state SET uidvalidity=:uv, last_uid_seen=0 WHERE account_id=:a AND folder=:f',
                ['uv'=>$uidvalidity, 'a'=>$account_id, 'f'=>$folder]
            );
        }
        $last_uid = 0;
    } else {
        $last_uid = (int)$state['last_uid_seen'];
    }

    // 3. Find UIDs to fetch
    $all_uids = @imap_search($conn, 'ALL', SE_UID);
    if ($all_uids === false) $all_uids = [];
    sort($all_uids, SORT_NUMERIC);

    $new_uids = array_values(array_filter($all_uids, fn($u) => (int)$u > $last_uid));

    $new_count = 0;
    $highest_uid = $last_uid;

    foreach ($new_uids as $uid) {
        $uid = (int)$uid;
        try {
            email_sync_one_message($conn, $account_id, $folder, $uidvalidity, $uid);
            $new_count++;
        } catch (Throwable $e) {
            app_log("email sync: account {$account_id} folder {$folder} uid {$uid} failed: " . $e->getMessage());
        }
        if ($uid > $highest_uid) $highest_uid = $uid;

        if ($new_count % 25 === 0) {
            db_exec(
                'UPDATE email_sync_state SET last_uid_seen=:u, last_sync_at=NOW() WHERE account_id=:a AND folder=:f',
                ['u'=>$highest_uid, 'a'=>$account_id, 'f'=>$folder]
            );
        }
    }

    db_exec(
        "UPDATE email_sync_state
            SET last_uid_seen=:u, last_sync_at=NOW(),
                last_full_sync = CASE WHEN last_full_sync IS NULL THEN NOW() ELSE last_full_sync END,
                last_error=NULL
          WHERE account_id=:a AND folder=:f",
        ['u'=>$highest_uid, 'a'=>$account_id, 'f'=>$folder]
    );

    // Refresh flags only for INBOX (Sent rarely needs flag updates)
    if ($folder === 'INBOX') {
        email_sync_refresh_flags($conn, $account_id, $folder);
    }

    imc_close($conn);

    return [
        'ok' => true,
        'message' => "Folder {$folder}: {$new_count} new",
        'new_count' => $new_count,
    ];
}

/**
 * Fetch and store one IMAP message.
 */
function email_sync_one_message($conn, int $account_id, string $folder, int $uidvalidity, int $uid): void {
    // Headers
    $header = @imap_rfc822_parse_headers(imap_fetchheader($conn, $uid, FT_UID));
    if (!$header) throw new RuntimeException("Couldn't fetch headers for UID $uid");

    $message_id  = isset($header->message_id) ? trim($header->message_id) : null;
    $in_reply_to = isset($header->in_reply_to) ? trim($header->in_reply_to) : null;
    $references  = isset($header->references) ? trim($header->references) : null;
    $subject     = imc_decode_header($header->subject ?? '');

    $from_list = imc_parse_address_header($header->fromaddress ?? null);
    $from_name = $from_list[0]['name'] ?? '';
    $from_email = $from_list[0]['email'] ?? '';

    $to_list = imc_parse_address_header($header->toaddress ?? null);
    $cc_list = imc_parse_address_header($header->ccaddress ?? null);

    // Dates
    $sent_at = !empty($header->date)
        ? date('Y-m-d H:i:s', strtotime($header->date) ?: time())
        : null;
    $overview = @imap_fetch_overview($conn, (string)$uid, FT_UID);
    $received_at = null;
    $size_bytes = null;
    $is_seen = 0; $is_flagged = 0; $is_answered = 0;
    if (is_array($overview) && !empty($overview[0])) {
        $ov = $overview[0];
        $received_at = !empty($ov->date) ? date('Y-m-d H:i:s', strtotime($ov->date) ?: time()) : null;
        $size_bytes  = (int)($ov->size ?? 0);
        $is_seen     = !empty($ov->seen) ? 1 : 0;
        $is_flagged  = !empty($ov->flagged) ? 1 : 0;
        $is_answered = !empty($ov->answered) ? 1 : 0;
    }

    // Body + attachments
    $parts = imc_fetch_message_parts($conn, $uid);
    $body_text = $parts['text'];
    $body_html = $parts['html'];
    $attachments = $parts['attachments'];

    // Synthesise plain text if only HTML exists, for snippet/search
    $snippet_source = $body_text !== '' ? $body_text : strip_tags($body_html);
    $snippet = imc_make_snippet($snippet_source);

    db_insert('email_messages', [
        'account_id'      => $account_id,
        'folder'          => $folder,
        'uid'             => $uid,
        'uidvalidity'     => $uidvalidity,
        'message_id'      => $message_id ? mb_substr($message_id, 0, 255) : null,
        'in_reply_to'     => $in_reply_to ? mb_substr($in_reply_to, 0, 255) : null,
        'references'      => $references,

        'subject'         => $subject !== '' ? mb_substr($subject, 0, 500) : null,
        'from_name'       => $from_name !== '' ? mb_substr($from_name, 0, 255) : null,
        'from_email'      => $from_email !== '' ? mb_substr($from_email, 0, 255) : null,
        'to_list'         => $to_list ? imc_address_list_to_string($to_list) : null,
        'cc_list'         => $cc_list ? imc_address_list_to_string($cc_list) : null,
        'sent_at'         => $sent_at,
        'received_at'     => $received_at,

        'snippet'         => $snippet !== '' ? mb_substr($snippet, 0, 500) : null,
        'body_text'       => $body_text !== '' ? $body_text : null,
        'body_html'       => $body_html !== '' ? $body_html : null,

        'has_attachments' => !empty($attachments) ? 1 : 0,
        'attachment_meta' => !empty($attachments) ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null,
        'size_bytes'      => $size_bytes,

        'is_seen'         => $is_seen,
        'is_flagged'      => $is_flagged,
        'is_answered'     => $is_answered,
    ]);
}

/**
 * Refresh seen/answered/flagged flags for recently-synced messages.
 * Cheap because we only ask IMAP for the flag bits, not the bodies.
 */
function email_sync_refresh_flags($conn, int $account_id, string $folder): void {
    $recent = db_all(
        'SELECT id, uid FROM email_messages
          WHERE account_id=:a AND folder=:f AND synced_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
          ORDER BY uid DESC LIMIT 1000',
        ['a'=>$account_id, 'f'=>$folder]
    );
    if (empty($recent)) return;

    $uids = array_map(fn($r) => (int)$r['uid'], $recent);
    $uid_csv = implode(',', $uids);
    $ov = @imap_fetch_overview($conn, $uid_csv, FT_UID);
    if (!is_array($ov)) return;

    $by_uid = [];
    foreach ($ov as $o) $by_uid[(int)$o->uid] = $o;

    foreach ($recent as $r) {
        $o = $by_uid[(int)$r['uid']] ?? null;
        if (!$o) continue;
        db_exec(
            "UPDATE email_messages
                SET is_seen=:s, is_flagged=:f, is_answered=:a
              WHERE id=:id",
            [
                's' => !empty($o->seen) ? 1 : 0,
                'f' => !empty($o->flagged) ? 1 : 0,
                'a' => !empty($o->answered) ? 1 : 0,
                'id' => (int)$r['id'],
            ]
        );
    }
}

/**
 * Null out body_text/body_html for messages older than EMAIL_BODY_KEEP_DAYS.
 * Snippet, subject, and metadata are preserved.
 * Returns count of messages evicted in this pass.
 */
function email_sync_evict_old_bodies(): int {
    $r = db_exec(
        "UPDATE email_messages
            SET body_text = NULL, body_html = NULL, body_evicted_at = NOW()
          WHERE body_evicted_at IS NULL
            AND (body_text IS NOT NULL OR body_html IS NOT NULL)
            AND sent_at < DATE_SUB(NOW(), INTERVAL :d DAY)",
        ['d' => EMAIL_BODY_KEEP_DAYS]
    );
    // db_exec result is the PDOStatement on UPDATE in our wrapper; rowCount works
    return is_object($r) && method_exists($r, 'rowCount') ? $r->rowCount() : 0;
}

/**
 * Fetch a message body on demand from IMAP. Used when a user opens
 * a message whose body has been evicted (older than 90 days).
 * Returns ['text'=>, 'html'=>, 'attachments'=>...] same shape as
 * imc_fetch_message_parts.
 */
function email_fetch_body_on_demand(array $acct, string $folder, int $uid): array {
    $conn = imc_open($acct, $folder);
    try {
        return imc_fetch_message_parts($conn, $uid);
    } finally {
        imc_close($conn);
    }
}

/**
 * Fetch a single attachment's bytes on demand. Returns ['data'=>bytes, 'mime'=>, 'name'=>]
 */
function email_fetch_attachment(array $acct, string $folder, int $uid, string $part_id): array {
    $conn = imc_open($acct, $folder);
    try {
        $structure = @imap_fetchstructure($conn, $uid, FT_UID);
        if (!$structure) throw new RuntimeException('Message structure not found');

        // Walk to find the part with this id, mirroring imc_fetch_message_parts
        $target_part = null;
        $walk = function ($part, string $section) use (&$walk, $part_id, &$target_part) {
            if ($section === $part_id) {
                $target_part = $part;
                return;
            }
            if (!empty($part->parts)) {
                $i = 0;
                foreach ($part->parts as $sp) {
                    $i++;
                    $next = $section === '' ? (string)$i : $section . '.' . $i;
                    $walk($sp, $next);
                    if ($target_part) return;
                }
            }
        };
        if (!empty($structure->parts)) {
            $i = 0;
            foreach ($structure->parts as $p) {
                $i++;
                $walk($p, (string)$i);
                if ($target_part) break;
            }
        } else {
            $walk($structure, '1');
        }
        if (!$target_part) throw new RuntimeException("Attachment part {$part_id} not found");

        $raw = @imap_fetchbody($conn, $uid, $part_id, FT_UID);
        $data = imc_decode_part_body($raw, (int)($target_part->encoding ?? 0));

        // Build name + mime
        $name = '';
        if (!empty($target_part->dparameters)) {
            foreach ($target_part->dparameters as $dp) if (strtolower($dp->attribute) === 'filename') $name = imc_decode_header($dp->value);
        }
        if ($name === '' && !empty($target_part->parameters)) {
            foreach ($target_part->parameters as $p) if (strtolower($p->attribute) === 'name') $name = imc_decode_header($p->value);
        }
        if ($name === '') $name = 'attachment';

        static $type_map = [0=>'text',1=>'multipart',2=>'message',3=>'application',4=>'audio',5=>'image',6=>'video',7=>'other'];
        $mime = ($type_map[$target_part->type ?? 0] ?? 'application') . '/' . strtolower($target_part->subtype ?? 'octet-stream');

        return ['data' => $data, 'mime' => $mime, 'name' => $name];
    } finally {
        imc_close($conn);
    }
}

/**
 * Sync all active accounts. Returns per-account results.
 */
function email_sync_all_active(): array {
    $accounts = db_all('SELECT * FROM email_accounts WHERE is_active=1');
    $results = [];
    foreach ($accounts as $acct) {
        $results[(int)$acct['id']] = email_sync_account($acct);
    }
    return $results;
}