<?php

declare(strict_types=1);

namespace Atdt\Import\Sources;

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

/**
 * Synchronet SMB message bases (SPEC-IMPORT §9): sets of .shd (headers)
 * .sdt (text) .sid (index) sharing a stem, found under data/subs/ on a
 * live board. Implemented against the Synchronet Message Base
 * specification: each header record announces its own total length, a
 * fixed portion is followed by fixed-length dfields (which locate the
 * body in the .sdt) and variable-length hfields (which carry sender,
 * recipient, and subject). The record length field is the ground truth
 * for walking; the parser self-checks the hfield region and refuses a
 * base whose records do not parse cleanly rather than importing noise.
 */
final class Smb extends Source
{
    private const SHD_BASE = 'SMB' . "\x1a";
    // Fixed msghdr_t portion per smbdefs.h: id[4] type u16 version u16
    // length u16 attr u16 auxattr u32 netattr u32 when_written(time u32
    // zone i16) when_imported(u32+i16) number u32 thread_back u32
    // thread_next u32 thread_first u32 delivery_attempts u16
    // times_downloaded u16 last_downloaded u32 offset u32 dfields u16
    private const FIXED = 62;
    private const OFF_LENGTH = 8;
    private const OFF_ATTR = 10;
    private const OFF_WRITTEN = 20;
    private const OFF_NUMBER = 32;
    private const OFF_THREAD_BACK = 36;
    private const OFF_DATA = 56;
    private const OFF_NDFIELDS = 60;
    private const ATTR_DELETE = 0x02;
    private const HF_SENDER = 0x40;
    private const HF_RECIPIENT = 0x00;
    private const HF_SUBJECT = 0x60;

    public function open(string $dir): array
    {
        $stems = [];
        foreach (glob($dir . '/*') ?: [] as $f) {
            if (is_file($f) && preg_match('/\.shd$/i', $f) === 1) {
                $stems[] = substr(basename($f), 0, -4);
            }
        }
        sort($stems, SORT_NATURAL | SORT_FLAG_CASE);
        if ($stems === []) {
            return ['ok' => false, 'error' => 'No .shd files found. A Synchronet message base is a set of '
                . '.shd/.sdt/.sid files per sub-board; look under data/subs/.', 'areas' => [], 'units' => []];
        }
        $areas = [];
        $units = [];
        foreach ($stems as $stem) {
            $shd = self::sibling($dir, $stem, 'shd');
            $sig = $shd === null ? '' : (string)@file_get_contents($shd, false, null, 0, 4);
            if ($sig !== self::SHD_BASE) {
                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 .shd were found but none carry the SMB '
                . '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);
        $off = (int)($cursor['off'] ?? 0);
        $skipped = 0;
        $emitted = 0;
        while ($u < count($units) && $emitted < $limit) {
            $stem = (string)$units[$u];
            $shdPath = self::sibling($dir, $stem, 'shd');
            $sdtPath = self::sibling($dir, $stem, 'sdt');
            if ($shdPath === null || $sdtPath === null) {
                $u++;
                $off = 0;
                continue;
            }
            $shd = fopen($shdPath, 'rb');
            $sdt = fopen($sdtPath, 'rb');
            if ($shd === false || $sdt === false) {
                $u++;
                $off = 0;
                continue;
            }
            $size = (int)filesize($shdPath);
            if ($off === 0) {
                // smbhdr_t: id[4] version u16 length u16 -> the status
                // block; headers begin at header_offset within it.
                $bh = (string)fread($shd, 8 + 28);
                $off = strlen($bh) >= 16 ? 8 + (int)self::u16($bh, 6) : $size;
                $hdrOff = strlen($bh) >= 20 ? self::u32($bh, 8 + 8) : 0;
                if ($hdrOff > 0 && $hdrOff < $size) {
                    $off = $hdrOff;                      // the base says where headers start
                }
            }
            $key = strtoupper($stem);
            while ($off + self::FIXED <= $size && $emitted < $limit) {
                fseek($shd, $off);
                $h = (string)fread($shd, self::FIXED);
                if (strlen($h) < self::FIXED || substr($h, 0, 3) !== 'SHD') {
                    // Records are length-aligned; scan forward one block
                    // rather than importing noise.
                    $off += 256;
                    $off -= $off % 256;
                    if (substr($h, 0, 3) !== 'SHD') {
                        $probe = $this->scanForward($shd, $off, $size);
                        if ($probe === null) {
                            break;
                        }
                        $off = $probe;
                        continue;
                    }
                    continue;
                }
                $recLen = self::u16($h, self::OFF_LENGTH);
                if ($recLen < self::FIXED || $off + $recLen > $size) {
                    break;
                }
                $rest = (string)fread($shd, $recLen - self::FIXED);
                $attr = self::u16($h, self::OFF_ATTR);
                $nd = self::u16($h, self::OFF_NDFIELDS);
                $fields = self::parse($rest, $nd);
                if ($fields === null) {
                    $skipped++;
                    $off += max($recLen, self::FIXED);
                    continue;
                }
                if (($attr & self::ATTR_DELETE) !== 0) {
                    $skipped++;
                    $off += $recLen;
                    continue;
                }
                $dataOff = self::u32($h, self::OFF_DATA);
                $body = '';
                foreach ($fields['dfields'] as $df) {
                    fseek($sdt, $dataOff + $df['offset']);
                    $chunk = (string)fread($sdt, min($df['length'], 4194304));
                    $nul = strpos($chunk, "\0");
                    $body .= $nul === false ? $chunk : substr($chunk, 0, $nul);
                    break;                               // dfield 0 is the body
                }
                $number = self::u32($h, self::OFF_NUMBER);
                $back = self::u32($h, self::OFF_THREAD_BACK);
                $emit([
                    'area_key' => $key,
                    'from_raw' => $fields['hfields'][self::HF_SENDER] ?? '',
                    'to_raw' => $fields['hfields'][self::HF_RECIPIENT] ?? '',
                    'subject_raw' => $fields['hfields'][self::HF_SUBJECT] ?? '',
                    'body_raw' => $body,
                    'date' => $n->dateFromUnix(self::u32($h, self::OFF_WRITTEN)),
                    'id' => (string)$number,
                    'reply_to' => $back > 0 ? (string)$back : null,
                ]);
                $emitted++;
                $off += $recLen;
            }
            fclose($shd);
            fclose($sdt);
            if ($off + self::FIXED > $size) {
                $u++;
                $off = 0;
            } else {
                break;
            }
        }
        return ['cursor' => ['u' => $u, 'off' => $off], 'done' => $u >= count($units), 'skipped' => $skipped];
    }

    /**
     * Parse the variable region: dfields (u16 type, u32 offset,
     * u32 length) x nd, then hfields (u16 type, u16 length, bytes) to
     * the end. Returns null when the region does not parse cleanly:
     * the self-check that keeps a layout mismatch loud (§9).
     * @return ?array{dfields: list<array{type:int,offset:int,length:int}>, hfields: array<int,string>}
     */
    private static function parse(string $rest, int $nd): ?array
    {
        if ($nd > 64) {
            return null;
        }
        $need = $nd * 10;
        if (strlen($rest) < $need) {
            return null;
        }
        $dfields = [];
        for ($i = 0; $i < $nd; $i++) {
            $o = $i * 10;
            $dfields[] = [
                'type' => self::u16($rest, $o),
                'offset' => self::u32($rest, $o + 2),
                'length' => self::u32($rest, $o + 6),
            ];
        }
        $hfields = [];
        $o = $need;
        $len = strlen($rest);
        while ($o + 4 <= $len) {
            $type = self::u16($rest, $o);
            $hlen = self::u16($rest, $o + 2);
            if ($o + 4 + $hlen > $len) {
                return null;
            }
            if (!isset($hfields[$type])) {
                $hfields[$type] = substr($rest, $o + 4, $hlen);
            }
            $o += 4 + $hlen;
        }
        return ['dfields' => $dfields, 'hfields' => $hfields];
    }

    /** Find the next SHD signature after $from (corrupt-record resync). */
    private function scanForward($fh, int $from, int $size): ?int
    {
        $pos = $from;
        while ($pos + 4 <= $size) {
            fseek($fh, $pos);
            $chunk = (string)fread($fh, 65536);
            $hit = strpos($chunk, 'SHD' . "\x1a");
            if ($hit !== false) {
                return $pos + $hit;
            }
            if (strlen($chunk) < 65536) {
                return null;
            }
            $pos += 65536 - 3;
        }
        return null;
    }

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