<?php

declare(strict_types=1);

namespace Atdt\Import\Sources;

use Atdt\Import\Normalizer;
use Atdt\Import\Source;

/**
 * Squish message bases (SPEC-IMPORT §8): Maximus, Talisman, and
 * Squish-configured Mystic. One .SQD/.SQI pair per area by filename
 * stem. The .SQD is a linked list of frames walked from begin_frame;
 * each normal frame holds a fixed XMSG header, control (kludge) text,
 * then the body. The structure caps linked replies at nine per
 * message; threads wider than that import with the overflow as roots,
 * logged, never repaired (§8).
 *
 * Layout per SQUISH.TXT (Maximus source distribution):
 *  base header: begin_frame u32 at offset 104
 *  frame header (28): id u32 (0xAFAE4453), next u32, prev u32,
 *    frame_length u32, msg_length u32, clen u32, type u16, rsvd u16
 *  XMSG (238): attr u32, from[36], to[36], subj[72], orig[8], dest[8],
 *    date_written u16+u16 (DOS packed), date_arrived u16+u16,
 *    utc_ofs u16, reply_to u32, replies[9] u32, umsgid u32,
 *    ftsc_date[20]
 */
final class Squish extends Source
{
    private const FRAME_ID = 0xAFAE4453;
    private const XMSG = 238;

    public function open(string $dir): array
    {
        $stems = [];
        foreach (glob($dir . '/*') ?: [] as $f) {
            if (is_file($f) && preg_match('/\.sqd$/i', $f) === 1) {
                $stems[] = substr(basename($f), 0, -4);
            }
        }
        sort($stems, SORT_NATURAL | SORT_FLAG_CASE);
        if ($stems === []) {
            return ['ok' => false, 'error' => 'No .SQD files found. A Squish message base is a .sqd and .sqi '
                . 'pair per area, in one directory.', 'areas' => [], 'units' => []];
        }
        $areas = [];
        $units = [];
        foreach ($stems as $stem) {
            $sqd = self::sibling($dir, $stem, 'sqd');
            $head = $sqd === null ? '' : (string)@file_get_contents($sqd, false, null, 0, 132);
            if (strlen($head) < 132) {
                continue;
            }
            $key = strtoupper($stem);
            $areas[$key] = ['name' => $key, 'tag' => $key, 'descr' => '', 'echo_tag' => null];
            $units[] = $stem;
        }
        if ($units === []) {
            return ['ok' => false, 'error' => 'Files ending in .sqd were found but none parse as a Squish '
                . 'base. Check the format choice.', 'areas' => [], 'units' => []];
        }
        return ['ok' => true, 'error' => null, 'areas' => $areas, 'units' => $units];
    }

    public function walk(string $dir, array $units, array $cursor, int $limit, Normalizer $n, callable $emit): array
    {
        $u = (int)($cursor['u'] ?? 0);
        $off = (int)($cursor['off'] ?? -1);              // -1: start at begin_frame
        $skipped = 0;
        $emitted = 0;
        $guard = 0;
        while ($u < count($units) && $emitted < $limit) {
            $stem = (string)$units[$u];
            $sqd = self::sibling($dir, $stem, 'sqd');
            if ($sqd === null) {
                $u++;
                $off = -1;
                continue;
            }
            $fh = fopen($sqd, 'rb');
            if ($fh === false) {
                $u++;
                $off = -1;
                continue;
            }
            $size = (int)filesize($sqd);
            if ($off < 0) {
                $head = (string)fread($fh, 132);
                $off = strlen($head) >= 108 ? self::u32($head, 104) : 0;
            }
            $key = strtoupper($stem);
            while ($off > 0 && $off + 28 <= $size && $emitted < $limit) {
                if (++$guard > 500000) {                 // a corrupt chain must not spin forever
                    $off = 0;
                    break;
                }
                fseek($fh, $off);
                $fr = (string)fread($fh, 28);
                if (strlen($fr) < 28 || self::u32($fr, 0) !== self::FRAME_ID) {
                    $off = 0;
                    break;
                }
                $next = self::u32($fr, 4);
                $msgLen = self::u32($fr, 16);
                $clen = self::u32($fr, 20);
                $type = self::u16($fr, 24);
                if ($type !== 0 || $msgLen < self::XMSG) {
                    $skipped++;
                    $off = $next;
                    continue;
                }
                $x = (string)fread($fh, self::XMSG);
                if (strlen($x) < self::XMSG) {
                    $off = 0;
                    break;
                }
                if ($clen > 0 && $clen < $msgLen) {
                    fread($fh, $clen);                   // kludges: dropped (§14.3 strips SOH anyway)
                }
                $bodyLen = $msgLen - self::XMSG - min($clen, $msgLen);
                $body = $bodyLen > 0 ? (string)fread($fh, min($bodyLen, 4194304)) : '';
                $nul = strpos($body, "\0");
                if ($nul !== false) {
                    $body = substr($body, 0, $nul);
                }
                $replyTo = self::u32($x, 174);
                $date = $n->dateFromDos(self::u16($x, 164), self::u16($x, 166))
                    ?? $n->dateFromFts(self::zstr($x, 218, 20));
                $emit([
                    'area_key' => $key,
                    'from_raw' => self::zstr($x, 4, 36),
                    'to_raw' => self::zstr($x, 40, 36),
                    'subject_raw' => self::zstr($x, 76, 72),
                    'body_raw' => $body,
                    'date' => $date,
                    'id' => (string)self::u32($x, 214),
                    'reply_to' => $replyTo > 0 ? (string)$replyTo : null,
                ]);
                $emitted++;
                $off = $next;
            }
            fclose($fh);
            if ($off <= 0) {
                $u++;
                $off = -1;
            } else {
                break;                                   // chunk full mid-area
            }
        }
        return ['cursor' => ['u' => $u, 'off' => $off], 'done' => $u >= count($units), 'skipped' => $skipped];
    }

    private static function sibling(string $dir, string $stem, string $ext): ?string
    {
        foreach ([$ext, strtoupper($ext)] as $e) {
            if (is_file("$dir/$stem.$e")) {
                return "$dir/$stem.$e";
            }
        }
        foreach (glob($dir . '/*') ?: [] as $f) {
            if (strcasecmp(basename($f), "$stem.$ext") === 0 && is_file($f)) {
                return $f;
            }
        }
        return null;
    }
}
