<?php

declare(strict_types=1);

namespace Atdt;

/**
 * Message bases + private mail domain API (SPEC §10.5). Screens never touch
 * PDO; everything routes through here.
 *
 * Per-area numbering: num = MAX+1 inside a transaction, guaranteed by the
 * UNIQUE(area_id, num) index — a concurrent post collides and retries. The
 * FTN-ready fields (§3.3) are populated on every post: msgid
 * "<TAG.N@host>", reply_msgid for replies.
 */
final class Messages
{
    public const MAIL_TAG = '_MAIL';
    // §7.5 hard limits, raised 200 → 1,000 lines in 1.6.0 for imported
    // archives; the byte cap rises with it (1,000 × 79 cols ≈ 79 KB).
    public const MAX_LINES = 1000;
    public const MAX_BYTES = 131072;   // 128 KB

    // ---- areas -----------------------------------------------------------

    /**
     * Readable local areas for this caller, with totals + unread counts.
     * Rows arrive in display order — categories by their sort (each area
     * carries its category's name as cat_name), loose areas last — so a
     * pick-by-number and the new scan both follow the shelf layout. A board
     * with no categories comes back as the same flat list it always was.
     * @return list<array<string, mixed>>
     */
    public static function areasFor(Db $db, ?array $user): array
    {
        $areas = $db->all(
            "SELECT a.*, c.name AS cat_name
             FROM message_areas a
             LEFT JOIN message_categories c ON c.id = a.category_id
             WHERE a.kind = 'local'
             ORDER BY COALESCE(c.sort, 2147483647), COALESCE(c.id, 2147483647), a.sort, a.id"
        );
        $out = [];
        foreach ($areas as $area) {
            if (!Acs::check((string)$area['acs_read'], $user)) {
                continue;
            }
            $areaId = (int)$area['id'];
            $area['total'] = self::count($db, $areaId);
            $area['unread'] = self::unreadCount($db, $user, $areaId);
            $out[] = $area;
        }
        return $out;
    }

    /** "Category / Area" when shelved, the bare name when loose (headers). */
    public static function areaLabel(array $area): string
    {
        $name = (string)$area['name'];
        $cat = trim((string)($area['cat_name'] ?? ''));
        return mb_strimwidth($cat === '' ? $name : $cat . ' / ' . $name, 0, 44);
    }

    public static function area(Db $db, int $id): ?array
    {
        return $db->one(
            'SELECT a.*, c.name AS cat_name
             FROM message_areas a
             LEFT JOIN message_categories c ON c.id = a.category_id
             WHERE a.id = ?',
            [$id]
        );
    }

    /** The caller's current area (users.prefs), validated + ACS-checked. */
    public static function currentArea(Db $db, ?array $user): ?array
    {
        $areas = self::areasFor($db, $user);
        if ($areas === []) {
            return null;
        }
        $prefId = (int)Users::getPref($db, $user, 'area', 0);
        foreach ($areas as $area) {
            if ((int)$area['id'] === $prefId) {
                return $area;
            }
        }
        return $areas[0];
    }

    public static function count(Db $db, int $areaId): int
    {
        return (int)$db->val(
            'SELECT COUNT(*) FROM messages WHERE area_id = ? AND deleted = 0',
            [$areaId]
        );
    }

    // ---- reading ---------------------------------------------------------

    public static function byNum(Db $db, int $areaId, int $num): ?array
    {
        return $db->one(
            'SELECT * FROM messages WHERE area_id = ? AND num = ? AND deleted = 0',
            [$areaId, $num]
        );
    }

    public static function byId(Db $db, int $id): ?array
    {
        return $db->one('SELECT * FROM messages WHERE id = ? AND deleted = 0', [$id]);
    }

    /** Next/prev existing num from $from (skips deletions); null at the edge. */
    public static function neighborNum(Db $db, int $areaId, int $from, int $dir): ?int
    {
        $sql = $dir > 0
            ? 'SELECT MIN(num) FROM messages WHERE area_id = ? AND num > ? AND deleted = 0'
            : 'SELECT MAX(num) FROM messages WHERE area_id = ? AND num < ? AND deleted = 0';
        $n = $db->val($sql, [$areaId, $from]);
        return $n === null ? null : (int)$n;
    }

    /**
     * The area's messages as a threaded tree, flattened for a list screen:
     * roots ordered by their posted date — newest-first by default (1.6.0;
     * 'old' flips it) — replies nested beneath their parent in posting
     * order, whatever the root order. Sorting on the date rather than the
     * number means a console-side date fix re-files the thread on its own.
     * Each row carries depth (capped at 6) and last (bool, final sibling —
     * for └─ vs ├─ tree glyphs). Replies whose parent is gone surface as
     * roots so nothing ever disappears from the list.
     *
     * @return list<array<string, mixed>>
     */
    public static function threadList(Db $db, int $areaId, string $sort = 'new'): array
    {
        $rows = $db->all(
            'SELECT id, num, from_user_id, from_name, to_name, subject, posted_at, reply_to_id
             FROM messages WHERE area_id = ? AND deleted = 0 ORDER BY num',
            [$areaId]
        );
        $byId = [];
        foreach ($rows as $r) {
            $byId[(int)$r['id']] = $r;
        }
        $children = [];
        $roots = [];
        foreach ($rows as $r) {
            $pid = $r['reply_to_id'] === null ? null : (int)$r['reply_to_id'];
            if ($pid !== null && isset($byId[$pid])) {
                $children[$pid][] = (int)$r['id'];
            } else {
                $roots[] = (int)$r['id'];
            }
        }
        usort($roots, function (int $a, int $b) use ($byId, $sort): int {
            $cmp = strcmp((string)$byId[$a]['posted_at'], (string)$byId[$b]['posted_at'])
                ?: (int)$byId[$a]['num'] <=> (int)$byId[$b]['num'];
            return $sort === 'old' ? $cmp : -$cmp;
        });
        $out = [];
        $walk = function (int $id, int $depth, bool $last) use (&$walk, &$out, $byId, $children): void {
            $m = $byId[$id];
            $m['depth'] = $depth;
            $m['last'] = $last;
            $out[] = $m;
            $kids = $children[$id] ?? [];
            $n = count($kids);
            foreach ($kids as $i => $kid) {
                $walk($kid, min($depth + 1, 6), $i === $n - 1);
            }
        };
        $n = count($roots);
        foreach ($roots as $i => $id) {
            $walk($id, 0, $i === $n - 1);
        }
        return $out;
    }

    public static function lastNum(Db $db, int $areaId): int
    {
        return (int)$db->val(
            'SELECT COALESCE(MAX(num), 0) FROM messages WHERE area_id = ? AND deleted = 0',
            [$areaId]
        );
    }

    public static function lastRead(Db $db, ?array $user, int $areaId): int
    {
        if ($user === null) {
            return 0;
        }
        return (int)$db->val(
            'SELECT last_num FROM lastread WHERE user_id = ? AND area_id = ?',
            [(int)$user['id'], $areaId],
            0
        );
    }

    /** High-water mark only ever rises (§10.5): everything at or below it
     *  is read. Individual reads ABOVE it belong to markReadOne (1.6.1). */
    public static function markRead(Db $db, ?array $user, int $areaId, int $num): void
    {
        if ($user === null) {
            return;
        }
        $updated = $db->q(
            'UPDATE lastread SET last_num = ? WHERE user_id = ? AND area_id = ? AND last_num < ?',
            [$num, (int)$user['id'], $areaId, $num]
        )->rowCount();
        if ($updated === 0 && self::lastRead($db, $user, $areaId) < $num) {
            try {
                $db->insert('lastread', [
                    'user_id' => (int)$user['id'], 'area_id' => $areaId, 'last_num' => $num,
                ]);
            } catch (\PDOException $e) {
                $db->q(
                    'UPDATE lastread SET last_num = ? WHERE user_id = ? AND area_id = ? AND last_num < ?',
                    [$num, (int)$user['id'], $areaId, $num]
                );
            }
        }
        // Marks the pointer now covers are redundant; keep the table a sliver.
        $db->q(
            'DELETE FROM read_marks WHERE user_id = ? AND area_id = ? AND num <= ?',
            [(int)$user['id'], $areaId, $num]
        );
    }

    /**
     * One message actually read (1.6.1, owner report): reading a new REPLY
     * used to hoist the high-water mark straight over an older message
     * nobody had opened. Now a read above the pointer is remembered on its
     * own, and the pointer advances only across messages that are truly
     * covered -- read, the caller's own words, or gone.
     */
    public static function markReadOne(Db $db, ?array $user, int $areaId, int $num): void
    {
        if ($user === null) {
            return;
        }
        if ($num > self::lastRead($db, $user, $areaId)) {
            try {
                $db->insert('read_marks', [
                    'user_id' => (int)$user['id'], 'area_id' => $areaId, 'num' => $num,
                ]);
            } catch (\PDOException $e) {
                // already marked -- two tabs reading the same message
            }
        }
        self::compact($db, $user, $areaId);
    }

    /**
     * Walk the pointer up across covered messages, folding read marks in
     * behind it. Coverage: individually read, or authored by this caller
     * (own posts are never unread, §unreadCount). Deleted messages do not
     * block the walk -- they are invisible everywhere else too.
     */
    private static function compact(Db $db, array $user, int $areaId): void
    {
        $uid = (int)$user['id'];
        $last = self::lastRead($db, $user, $areaId);
        $to = $last;
        while (true) {
            $next = $db->val(
                'SELECT MIN(num) FROM messages WHERE area_id = ? AND deleted = 0 AND num > ?',
                [$areaId, $to]
            );
            if ($next === null) {
                break;
            }
            $covered = $db->val(
                'SELECT 1 FROM messages m WHERE m.area_id = ? AND m.num = ?
                   AND (m.from_user_id = ? OR EXISTS (
                        SELECT 1 FROM read_marks r
                         WHERE r.user_id = ? AND r.area_id = m.area_id AND r.num = m.num))',
                [$areaId, (int)$next, $uid, $uid]
            );
            if ($covered === null) {
                break;
            }
            $to = (int)$next;
        }
        if ($to > $last) {
            self::markRead($db, $user, $areaId, $to);
        }
    }

    /**
     * The nums this caller has read ABOVE the pointer, as a lookup set --
     * one query for a whole list render.
     * @return array<int, true>
     */
    public static function readSet(Db $db, ?array $user, int $areaId): array
    {
        if ($user === null) {
            return [];
        }
        $set = [];
        foreach ($db->all(
            'SELECT num FROM read_marks WHERE user_id = ? AND area_id = ?',
            [(int)$user['id'], $areaId]
        ) as $row) {
            $set[(int)$row['num']] = true;
        }
        return $set;
    }

    /**
     * A caller's own posts never count as unread (1.6.0 owner report:
     * a reply you just wrote showing as "new" to you is nonsense). The
     * high-water pointer can sit below your own message when older
     * unread mail exists, so the queries exclude the author outright --
     * and since 1.6.1 they also exclude read marks above the pointer,
     * so a reply read early no longer hides an older unread message.
     */
    public static function unreadCount(Db $db, ?array $user, int $areaId): int
    {
        return (int)$db->val(
            'SELECT COUNT(*) FROM messages m WHERE m.area_id = ? AND m.deleted = 0 AND m.num > ?
               AND (m.from_user_id IS NULL OR m.from_user_id != ?)
               AND NOT EXISTS (SELECT 1 FROM read_marks r
                    WHERE r.user_id = ? AND r.area_id = m.area_id AND r.num = m.num)',
            [$areaId, self::lastRead($db, $user, $areaId), (int)($user['id'] ?? 0), (int)($user['id'] ?? 0)]
        );
    }

    public static function firstUnreadNum(Db $db, ?array $user, int $areaId): ?int
    {
        $n = $db->val(
            'SELECT MIN(num) FROM messages m WHERE m.area_id = ? AND m.deleted = 0 AND m.num > ?
               AND (m.from_user_id IS NULL OR m.from_user_id != ?)
               AND NOT EXISTS (SELECT 1 FROM read_marks r
                    WHERE r.user_id = ? AND r.area_id = m.area_id AND r.num = m.num)',
            [$areaId, self::lastRead($db, $user, $areaId), (int)($user['id'] ?? 0), (int)($user['id'] ?? 0)]
        );
        return $n === null ? null : (int)$n;
    }

    // ---- posting ---------------------------------------------------------

    /**
     * Post into an area (public message or mail letter). Body arrives as
     * editor lines; limits enforced here as well as at the client (§7.5).
     *
     * @param array<string, mixed> $from the posting user row
     * @param list<string> $lines
     * @return array{id: int, num: int}
     */
    public static function post(
        Db $db,
        Settings $settings,
        int $areaId,
        array $from,
        string $toName,
        ?int $toUserId,
        string $subject,
        array $lines,
        ?array $replyTo = null
    ): array {
        $body = self::joinBody($lines);
        $subject = mb_strimwidth(trim($subject), 0, 70);
        if ($subject === '') {
            $subject = '(no subject)';
        }
        $area = self::area($db, $areaId);
        if ($area === null) {
            throw new \RuntimeException("No such message area #$areaId");
        }
        $host = parse_url((string)$settings->get('base_url', ''), PHP_URL_HOST) ?: 'atdt.local';

        $attempts = 0;
        while (true) {
            try {
                $res = $db->tx(function (Db $db) use ($area, $areaId, $from, $toName, $toUserId, $subject, $body, $replyTo, $host): array {
                    $num = 1 + (int)$db->val(
                        'SELECT COALESCE(MAX(num), 0) FROM messages WHERE area_id = ?',
                        [$areaId]
                    );
                    $id = $db->insert('messages', [
                        'area_id' => $areaId,
                        'num' => $num,
                        'from_user_id' => (int)$from['id'],
                        'from_name' => (string)$from['handle'],
                        'to_user_id' => $toUserId,
                        'to_name' => $toName === '' ? 'All' : $toName,
                        'subject' => $subject,
                        'body' => $body,
                        'posted_at' => now_utc(),
                        'reply_to_id' => $replyTo === null ? null : (int)$replyTo['id'],
                        'msgid' => '<' . $area['tag'] . '.' . $num . '@' . $host . '>',
                        'reply_msgid' => $replyTo === null ? null : (string)$replyTo['msgid'],
                        'origin' => null,
                        'kludges' => '{}',
                        'is_read' => 0,
                        'deleted' => 0,
                    ]);
                    return ['id' => $id, 'num' => $num];
                });
                self::selfRead($db, $areaId, $from, (int)$res['num']);
                return $res;
            } catch (\PDOException $e) {
                // UNIQUE(area_id, num) collision with a concurrent poster.
                if (++$attempts >= 5) {
                    throw $e;
                }
            }
        }
    }

    /**
     * Your own post is never "new" to you (1.6.0, owner report). Since
     * 1.6.1 this is just an individual read mark: caught up, the pointer
     * folds it in; with an unread backlog the mark waits above the
     * pointer without touching anyone else's words.
     */
    private static function selfRead(Db $db, int $areaId, array $from, int $num): void
    {
        if ((int)($from['id'] ?? 0) < 1) {
            return;
        }
        self::markReadOne($db, $from, $areaId, $num);
    }

    /** Post-side bookkeeping: users.posts++, call summary refresh (§8.2). */
    public static function recordPost(Db $db, array $user, array $sess): void
    {
        $db->q('UPDATE users SET posts = posts + 1 WHERE id = ?', [(int)$user['id']]);
        $n = (int)$db->val(
            'SELECT COUNT(*) FROM messages WHERE from_user_id = ? AND posted_at >= ?',
            [(int)$user['id'], (string)$sess['created_at']]
        );
        $db->q(
            'UPDATE call_log SET summary = ? WHERE off_at IS NULL AND node = ? AND on_at = ?',
            ["Posted $n msg" . ($n === 1 ? '' : 's'), (int)$sess['node'], (string)$sess['created_at']]
        );
    }

    /** @param list<string> $lines */
    public static function joinBody(array $lines): string
    {
        $lines = array_slice(array_map(
            fn($l) => preg_replace('/[\x00-\x08\x0b-\x1f\x7f]/', '', (string)$l) ?? '',
            $lines
        ), 0, self::MAX_LINES);
        $body = implode("\n", $lines);
        while (strlen($body) > self::MAX_BYTES) {
            array_pop($lines);
            $body = implode("\n", $lines);
        }
        return $body;
    }

    /**
     * Quote lines for the editor's ^Q picker (§7.5): "Ari> original text",
     * wrapped so the prefix + text still fit the 79-column editor.
     * @return list<string>
     */
    public static function quoteLines(array $msg): array
    {
        $initials = ucfirst(mb_strtolower(mb_substr(trim((string)$msg['from_name']), 0, 3)));
        if ($initials === '') {
            $initials = 'Msg';
        }
        $prefix = $initials . '> ';
        $width = 79 - mb_strlen($prefix);
        $out = [];
        foreach (explode("\n", (string)$msg['body']) as $line) {
            $line = rtrim($line);
            if ($line === '') {
                $out[] = rtrim($prefix);
                continue;
            }
            foreach (explode("\n", wordwrap($line, $width, "\n", true)) as $piece) {
                $out[] = $prefix . $piece;
            }
        }
        return array_slice($out, 0, self::MAX_LINES);
    }

    // ---- private mail (§10.5) -------------------------------------------

    public static function mailArea(Db $db): array
    {
        $area = $db->one('SELECT * FROM message_areas WHERE tag = ?', [self::MAIL_TAG]);
        if ($area === null) {
            throw new \RuntimeException('The _MAIL area is missing');
        }
        return $area;
    }

    /** Letters addressed to this user, newest first. @return list<array<string, mixed>> */
    public static function mailFor(Db $db, array $user): array
    {
        $areaId = (int)self::mailArea($db)['id'];
        return $db->all(
            'SELECT * FROM messages WHERE area_id = ? AND to_user_id = ? AND deleted = 0 ORDER BY id DESC',
            [$areaId, (int)$user['id']]
        );
    }

    /** Letters this user sent, newest first (1.5.0). @return list<array<string, mixed>> */
    public static function mailSentBy(Db $db, array $user): array
    {
        $areaId = (int)self::mailArea($db)['id'];
        return $db->all(
            'SELECT * FROM messages WHERE area_id = ? AND from_user_id = ? AND deleted = 0 ORDER BY id DESC',
            [$areaId, (int)$user['id']]
        );
    }

    public static function unreadMailCount(Db $db, array $user): int
    {
        $areaId = (int)self::mailArea($db)['id'];
        return (int)$db->val(
            'SELECT COUNT(*) FROM messages WHERE area_id = ? AND to_user_id = ? AND is_read = 0 AND deleted = 0',
            [$areaId, (int)$user['id']]
        );
    }

    /** @param list<string> $lines @return array{id: int, num: int} */
    public static function sendMail(
        Db $db,
        Settings $settings,
        array $from,
        array $toUser,
        string $subject,
        array $lines,
        ?array $replyTo = null
    ): array {
        return self::post(
            $db,
            $settings,
            (int)self::mailArea($db)['id'],
            $from,
            (string)$toUser['handle'],
            (int)$toUser['id'],
            $subject,
            $lines,
            $replyTo
        );
    }

    public static function markMailRead(Db $db, array $user, int $messageId): void
    {
        $db->q(
            'UPDATE messages SET is_read = 1 WHERE id = ? AND to_user_id = ?',
            [$messageId, (int)$user['id']]
        );
    }
}
