<?php

declare(strict_types=1);

namespace Atdt\Import\Sources;

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

/**
 * JAM message bases (SPEC-IMPORT §7): the highest-value parser in the
 * set, covering the wide DOS column (Mystic, RemoteAccess, EleBBS,
 * ProBoard, Telegard-with-JAM, and the rest). One base per area named
 * by filename stem: .JHR headers, .JDT text, .JDX index. Implemented
 * against the JAM specification (JAM.TXT, 1993): the index carries
 * one {ToNameCRC, HdrOffset} pair per message, offsets point into the
 * .JHR, and each header is a 76-byte fixed portion followed by
 * variable-length subfields. Never assume a fixed header size (§7.4).
 */
final class Jam extends Source
{
    private const FIXED = 76;
    private const DELETED = 0x80000000;
    private const SF_SENDER = 2;
    private const SF_RECEIVER = 3;
    private const SF_SUBJECT = 6;

    public function open(string $dir): array
    {
        $stems = [];
        foreach (glob($dir . '/*') ?: [] as $f) {
            if (is_file($f) && preg_match('/\.jhr$/i', $f) === 1) {
                $stems[] = substr(basename($f), 0, -4);
            }
        }
        sort($stems, SORT_NATURAL | SORT_FLAG_CASE);
        if ($stems === []) {
            return ['ok' => false, 'error' => 'No .JHR files found. A JAM message base is a set of files '
                . 'ending in .jhr, .jdt, and .jdx, one set per area, in one directory.',
                'areas' => [], 'units' => []];
        }
        $areas = [];
        $units = [];
        foreach ($stems as $stem) {
            $jhr = self::sibling($dir, $stem, 'jhr');
            $sig = $jhr === null ? '' : (string)@file_get_contents($jhr, false, null, 0, 4);
            if ($sig !== "JAM\0") {
                continue;                                // §16.1: not a JAM base
            }
            $key = strtoupper($stem);
            $areas[$key] = ['name' => $key, 'tag' => $key, 'descr' => '', 'echo_tag' => null];
            $units[] = $stem;
        }
        if ($units === []) {
            return ['ok' => false, 'error' => 'Files ending in .jhr were found but none carry the JAM '
                . 'signature. 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);
        $i = (int)($cursor['i'] ?? 0);
        $skipped = 0;
        $emitted = 0;
        while ($u < count($units) && $emitted < $limit) {
            $stem = (string)$units[$u];
            $jhrPath = self::sibling($dir, $stem, 'jhr');
            $jdxPath = self::sibling($dir, $stem, 'jdx');
            $jdtPath = self::sibling($dir, $stem, 'jdt');
            if ($jhrPath === null || $jdxPath === null || $jdtPath === null) {
                $u++;
                $i = 0;
                continue;
            }
            $jhr = fopen($jhrPath, 'rb');
            $jdx = fopen($jdxPath, 'rb');
            $jdt = fopen($jdtPath, 'rb');
            if ($jhr === false || $jdx === false || $jdt === false) {
                $u++;
                $i = 0;
                continue;
            }
            $entries = (int)(filesize($jdxPath) / 8);
            $key = strtoupper($stem);
            for (; $i < $entries && $emitted < $limit; $i++) {
                fseek($jdx, $i * 8);
                $ix = (string)fread($jdx, 8);
                if (strlen($ix) < 8) {
                    break;
                }
                $off = self::u32($ix, 4);
                if ($off === 0xFFFFFFFF) {               // vacant index slot
                    $skipped++;
                    continue;
                }
                fseek($jhr, $off);
                $h = (string)fread($jhr, self::FIXED);
                if (strlen($h) < self::FIXED || substr($h, 0, 4) !== "JAM\0") {
                    $skipped++;
                    continue;
                }
                $subLen = self::u32($h, 8);
                $attr = self::u32($h, 52);
                if (($attr & self::DELETED) !== 0) {
                    $skipped++;
                    continue;
                }
                $sub = $subLen > 0 && $subLen < 1048576 ? (string)fread($jhr, $subLen) : '';
                $fields = self::subfields($sub);
                $txtOff = self::u32($h, 60);
                $txtLen = self::u32($h, 64);
                fseek($jdt, $txtOff);
                $body = $txtLen > 0 && $txtLen < 4194304 ? (string)fread($jdt, $txtLen) : '';
                $msgNum = self::u32($h, 48);
                $replyTo = self::u32($h, 24);
                $emit([
                    'area_key' => $key,
                    'from_raw' => $fields[self::SF_SENDER] ?? '',
                    'to_raw' => $fields[self::SF_RECEIVER] ?? '',
                    'subject_raw' => $fields[self::SF_SUBJECT] ?? '',
                    'body_raw' => $body,
                    'date' => $n->dateFromLocalUnix(self::u32($h, 36)),
                    'id' => (string)$msgNum,
                    'reply_to' => $replyTo > 0 ? (string)$replyTo : null,
                ]);
                $emitted++;
            }
            fclose($jhr);
            fclose($jdx);
            fclose($jdt);
            if ($i >= $entries) {
                $u++;
                $i = 0;
            }
        }
        return ['cursor' => ['u' => $u, 'i' => $i], 'done' => $u >= count($units), 'skipped' => $skipped];
    }

    /**
     * Subfields per JAM.TXT: {u16 LoID, u16 HiID, u32 DatLen, bytes},
     * repeated for SubfieldLen bytes. Counts are never trusted (§7.4).
     * Fixed-header offsets used above, per the spec: signature 0,
     * Revision 4, ReservedWord 6, SubfieldLen 8, TimesRead 12,
     * MSGIDcrc 16, REPLYcrc 20, ReplyTo 24, Reply1st 28, ReplyNext 32,
     * DateWritten 36, DateReceived 40, DateProcessed 44,
     * MessageNumber 48, Attribute 52, Attribute2 56, Offset 60,
     * TxtLen 64, PasswordCRC 68, Cost 72.
     */
    private static function subfields(string $sub): array
    {
        $out = [];
        $n = strlen($sub);
        $o = 0;
        while ($o + 8 <= $n) {
            $lo = self::u16($sub, $o);
            $len = self::u32($sub, $o + 4);
            if ($len > $n - $o - 8) {
                break;                                   // §7.4: never trust a count
            }
            if (!isset($out[$lo])) {
                $out[$lo] = substr($sub, $o + 8, $len);
            }
            $o += 8 + $len;
        }
        return $out;
    }

    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;
    }
}
