<?php

declare(strict_types=1);

namespace Atdt\Import\Sources;

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

/**
 * The Hudson message base (SPEC-IMPORT §10): QuickBBS and its lineage.
 * One shared set of files covers every area: MSGHDR.BBS holds 187-byte
 * records, MSGTXT.BBS holds 128-byte text blocks (length byte + up to
 * 127 chars), and the area number rides each header. Areas arrive as
 * "Area N": there are no names in the base, so the mapping screen and
 * the sample panel carry the whole burden of recognition.
 */
final class Hudson extends Source
{
    private const HDR = 187;

    public function open(string $dir): array
    {
        $hdr = self::find($dir, 'MSGHDR.BBS');
        $txt = self::find($dir, 'MSGTXT.BBS');
        if ($hdr === null || $txt === null) {
            return ['ok' => false, 'error' => 'No Hudson base found: the directory must hold MSGHDR.BBS and '
                . 'MSGTXT.BBS (MSGIDX.BBS and MSGINFO.BBS usually ride along).', 'areas' => [], 'units' => []];
        }
        if ((int)filesize($hdr) % self::HDR !== 0) {
            return ['ok' => false, 'error' => 'MSGHDR.BBS is not a whole number of 187-byte records; '
                . 'this is not a Hudson message base.', 'areas' => [], 'units' => []];
        }
        // Text block numbering: most bases count records from 0, some
        // Pascal-flavored writers from 1. Probe the first active record
        // and keep whichever base yields printable text.
        $base = self::probeBase($hdr, $txt);
        return ['ok' => true, 'error' => null, 'areas' => [],
            'units' => [['hdr' => basename($hdr), 'txt' => basename($txt), 'base' => $base]]];
    }

    public function walk(string $dir, array $units, array $cursor, int $limit, Normalizer $n, callable $emit): array
    {
        $unit = (array)($units[0] ?? []);
        $hdr = self::find($dir, (string)($unit['hdr'] ?? 'MSGHDR.BBS'));
        $txt = self::find($dir, (string)($unit['txt'] ?? 'MSGTXT.BBS'));
        if ($hdr === null || $txt === null) {
            return ['cursor' => $cursor, 'done' => true, 'skipped' => 0];
        }
        $blockBase = (int)($unit['base'] ?? 0);
        $fh = fopen($hdr, 'rb');
        $ft = fopen($txt, 'rb');
        if ($fh === false || $ft === false) {
            return ['cursor' => $cursor, 'done' => true, 'skipped' => 0];
        }
        $total = (int)(filesize($hdr) / self::HDR);
        $txtBlocks = (int)(filesize($txt) / 128);
        $i = (int)($cursor['i'] ?? 0);
        $skipped = 0;
        $emitted = 0;
        for (; $i < $total && $emitted < $limit; $i++) {
            fseek($fh, $i * self::HDR);
            $r = (string)fread($fh, self::HDR);
            if (strlen($r) < self::HDR) {
                break;
            }
            $attr = ord($r[24]);
            if (($attr & 0x01) !== 0) {                  // deleted
                $skipped++;
                continue;
            }
            $board = ord($r[26]);
            $start = self::u16($r, 8) - $blockBase;
            $count = self::u16($r, 10);
            $body = '';
            for ($bk = 0; $bk < $count && $start + $bk < $txtBlocks; $bk++) {
                if ($start + $bk < 0) {
                    continue;
                }
                fseek($ft, ($start + $bk) * 128);
                $blk = (string)fread($ft, 128);
                if ($blk === '') {
                    break;
                }
                $body .= substr($blk, 1, min(ord($blk[0]), 127));
            }
            $replyTo = self::u16($r, 2);
            $emit([
                'area_key' => (string)$board,
                'from_raw' => self::pstr($r, 78, 36),
                'to_raw' => self::pstr($r, 42, 36),
                'subject_raw' => self::pstr($r, 114, 73),
                'body_raw' => $body,
                'date' => $n->dateFromShort(self::pstr($r, 33, 9), self::pstr($r, 27, 6)),
                'id' => (string)self::u16($r, 0),
                'reply_to' => $replyTo > 0 ? (string)$replyTo : null,
            ]);
            $emitted++;
        }
        fclose($fh);
        fclose($ft);
        return ['cursor' => ['i' => $i], 'done' => $i >= $total, 'skipped' => $skipped];
    }

    /** 0- or 1-based text blocks? Probe the first live record. */
    private static function probeBase(string $hdr, string $txt): int
    {
        $fh = fopen($hdr, 'rb');
        $ft = fopen($txt, 'rb');
        if ($fh === false || $ft === false) {
            return 0;
        }
        $total = (int)(filesize($hdr) / self::HDR);
        $best = 0;
        for ($i = 0; $i < min($total, 50); $i++) {
            $r = (string)fread($fh, self::HDR);
            if (strlen($r) < self::HDR || (ord($r[24]) & 0x01) !== 0) {
                continue;
            }
            $start = self::u16($r, 8);
            $scores = [];
            foreach ([0, 1] as $base) {
                fseek($ft, max(0, ($start - $base)) * 128);
                $blk = (string)fread($ft, 128);
                $text = $blk === '' ? '' : substr($blk, 1, min(ord($blk[0]), 127));
                $printable = $text === '' ? 0
                    : strlen(preg_replace('/[^\x20-\x7e\r\n]/', '', $text) ?? '') / strlen($text);
                $scores[$base] = $printable;
            }
            $best = ($scores[1] ?? 0) > ($scores[0] ?? 0) + 0.15 ? 1 : 0;
            break;
        }
        fclose($fh);
        fclose($ft);
        return $best;
    }

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