<?php

declare(strict_types=1);

namespace Atdt\Import\Sources;

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

/**
 * QWK packets (SPEC-IMPORT §12): the universal fallback. CONTROL.DAT
 * names the conferences; MESSAGES.DAT holds 128-byte blocks, one
 * header block per message then its text blocks, lines ending 0xE3.
 * Subjects cap at 25 in the classic format; QWKE packets carry the
 * long subject as a Subject: kludge at the top of the body, which is
 * lifted back where it belongs. QWK imports flat: the format's reply
 * linkage is too weak to trust (§14.7).
 */
final class Qwk extends Source
{
    public function open(string $dir): array
    {
        $control = self::find($dir, 'CONTROL.DAT');
        $messages = self::find($dir, 'MESSAGES.DAT');
        if ($messages === null) {
            return ['ok' => false, 'error' => 'No MESSAGES.DAT found. A QWK packet is a zip holding '
                . 'CONTROL.DAT and MESSAGES.DAT; upload the .qwk file itself.', 'areas' => [], 'units' => []];
        }
        $size = (int)filesize($messages);
        if ($size < 256 || $size % 128 !== 0) {
            return ['ok' => false, 'error' => 'MESSAGES.DAT is not a whole number of 128-byte blocks; '
                . 'this is not a QWK packet.', 'areas' => [], 'units' => []];
        }
        $areas = [];
        if ($control !== null) {
            $lines = preg_split('/\r\n|\r|\n/', (string)file_get_contents($control)) ?: [];
            // Line 11 (1-based) counts conferences minus one; pairs of
            // number and name follow. Parsed defensively: real packets
            // disagree about trailing lines.
            $n = (int)trim((string)($lines[10] ?? '-1'));
            $at = 11;
            for ($i = 0; $i <= $n && $at + 1 < count($lines); $i++, $at += 2) {
                $num = trim((string)$lines[$at]);
                $name = trim((string)$lines[$at + 1]);
                if ($num === '' || !ctype_digit($num)) {
                    break;
                }
                $areas[$num] = [
                    'name' => $name === '' ? "Conference $num" : $name,
                    'tag' => $name,
                    'descr' => '',
                    'echo_tag' => null,
                ];
            }
        }
        return ['ok' => true, 'error' => null, 'areas' => $areas,
            'units' => [basename($messages)]];
    }

    public function walk(string $dir, array $units, array $cursor, int $limit, Normalizer $n, callable $emit): array
    {
        $path = self::find($dir, (string)($units[0] ?? 'MESSAGES.DAT'));
        if ($path === null) {
            return ['cursor' => $cursor, 'done' => true, 'skipped' => 0];
        }
        $fh = fopen($path, 'rb');
        if ($fh === false) {
            return ['cursor' => $cursor, 'done' => true, 'skipped' => 0];
        }
        $blocks = (int)(filesize($path) / 128);
        $b = max(1, (int)($cursor['b'] ?? 1));          // block 0 is the copyright block
        $skipped = 0;
        $emitted = 0;
        while ($b < $blocks && $emitted < $limit) {
            fseek($fh, $b * 128);
            $h = (string)fread($fh, 128);
            if (strlen($h) < 128) {
                $b = $blocks;
                break;
            }
            $chunks = (int)trim(substr($h, 116, 6));
            if ($chunks < 1) {
                // Not a header block: drift one block and resync.
                $b++;
                $skipped++;
                continue;
            }
            $textBlocks = min($chunks - 1, $blocks - $b - 1);
            $text = $textBlocks > 0 ? (string)fread($fh, $textBlocks * 128) : '';
            $alive = ord($h[122]) !== 0xE2;             // 0xE2 = killed
            if ($alive) {
                $conf = self::u16($h, 123);
                $body = rtrim(str_replace("\xE3", "\n", $text));
                $subject = rtrim(substr($h, 71, 25));
                // QWKE: long headers ride the top of the body.
                [$body, $subject] = self::liftKludges($body, $subject);
                $emit([
                    'area_key' => (string)$conf,
                    'from_raw' => rtrim(substr($h, 46, 25)),
                    'to_raw' => rtrim(substr($h, 21, 25)),
                    'subject_raw' => $subject,
                    'body_raw' => $body,
                    'date' => $n->dateFromShort(rtrim(substr($h, 8, 8)), rtrim(substr($h, 16, 5))),
                    'id' => trim(substr($h, 1, 7)) . '@' . $b,
                    'reply_to' => null,                  // §14.7: QWK imports flat
                ]);
                $emitted++;
            } else {
                $skipped++;
            }
            $b += 1 + max(0, $textBlocks);
        }
        fclose($fh);
        return ['cursor' => ['b' => $b], 'done' => $b >= $blocks, 'skipped' => $skipped];
    }

    /** @return array{0: string, 1: string} body, subject */
    private static function liftKludges(string $body, string $subject): array
    {
        $lines = explode("\n", $body);
        $at = 0;
        while ($at < min(4, count($lines))) {
            $line = $lines[$at];
            if (preg_match('/^(To|From|Subject):\s?(.*)$/i', $line, $m) !== 1) {
                break;
            }
            if (strcasecmp($m[1], 'Subject') === 0 && trim($m[2]) !== '') {
                $subject = trim($m[2]);
            }
            $at++;
        }
        if ($at > 0) {
            if (isset($lines[$at]) && trim($lines[$at]) === '') {
                $at++;
            }
            $lines = array_slice($lines, $at);
        }
        return [implode("\n", $lines), $subject];
    }

    /** Case-insensitive file lookup inside the packet directory. */
    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;
    }
}
