<?php

declare(strict_types=1);

namespace Atdt\Import\Sources;

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

/**
 * The ATDT Import Format (SPEC-IMPORT §6): manifest.json plus one
 * newline-delimited JSON file per area. The permanent first-class
 * entry: it covers every machine ATDT will never parse natively, and
 * it is the format third-party exporters target.
 *
 * Strings are JSON strings. When the manifest declares an encoding
 * other than utf-8, each string carries the ORIGINAL bytes as
 * codepoints U+0000-U+00FF (the exporter writes byte 0xA4 as ¤),
 * and the chosen encoding decodes them; utf-8 strings pass through.
 */
final class Jsonl extends Source
{
    public function open(string $dir): array
    {
        $mf = $dir . '/manifest.json';
        if (!is_file($mf)) {
            return ['ok' => false, 'error' => 'No manifest.json found. An ATDT import zip holds manifest.json '
                . 'and one .jsonl file per area under areas/. The format documentation is on the ATDT website.',
                'areas' => [], 'units' => []];
        }
        $m = json_decode((string)file_get_contents($mf), true);
        if (!is_array($m) || (string)($m['format'] ?? '') !== 'atdt-import') {
            return ['ok' => false, 'error' => 'manifest.json is not an ATDT import manifest '
                . '(the format field must be "atdt-import").', 'areas' => [], 'units' => []];
        }
        if ((int)($m['version'] ?? 0) !== 1) {
            return ['ok' => false, 'error' => 'This manifest declares version ' . (int)($m['version'] ?? 0)
                . ' and this release reads version 1. It will not be guessed at.', 'areas' => [], 'units' => []];
        }
        $areas = [];
        $units = [];
        foreach ((array)($m['areas'] ?? []) as $a) {
            $file = (string)($a['file'] ?? '');
            // Inside areas/, no escapes: the zip extractor already jailed
            // entries, this guards a hand-built server folder too.
            if (!preg_match('#^areas/[A-Za-z0-9._-]+$#', $file) || !is_file($dir . '/' . $file)) {
                return ['ok' => false, 'error' => "The manifest lists \"$file\" and there is no such file under "
                    . 'areas/ (paths must stay inside areas/).', 'areas' => [], 'units' => []];
            }
            if ((string)($a['name'] ?? '') === '') {
                return ['ok' => false, 'error' => "The manifest entry for \"$file\" has no area name.",
                    'areas' => [], 'units' => []];
            }
            $areas[$file] = [
                'name' => (string)$a['name'],
                'tag' => (string)($a['tag'] ?? ''),
                'descr' => (string)($a['description'] ?? ''),
                'echo_tag' => isset($a['echo_tag']) && $a['echo_tag'] !== null ? (string)$a['echo_tag'] : null,
            ];
            $units[] = $file;
        }
        if ($areas === []) {
            return ['ok' => false, 'error' => 'The manifest lists no areas.', 'areas' => [], 'units' => []];
        }
        return ['ok' => true, 'error' => null, 'areas' => $areas, 'units' => $units,
            'defaults' => [
                'encoding' => (string)($m['encoding'] ?? 'cp437'),
                'timezone' => (string)($m['timezone'] ?? ''),
                'source' => (string)($m['source'] ?? ''),
            ]];
    }

    public function walk(string $dir, array $units, array $cursor, int $limit, Normalizer $n, callable $emit): array
    {
        $u = (int)($cursor['u'] ?? 0);
        $line = (int)($cursor['line'] ?? 0);
        $skipped = 0;
        $emitted = 0;
        while ($u < count($units) && $emitted < $limit) {
            $file = (string)$units[$u];
            $fh = fopen($dir . '/' . $file, 'rb');
            if ($fh === false) {
                $u++;
                $line = 0;
                continue;
            }
            for ($i = 0; $i < $line && fgets($fh) !== false; $i++);
            while ($emitted < $limit && ($raw = fgets($fh)) !== false) {
                $line++;
                if (trim($raw) === '') {
                    continue;
                }
                $r = json_decode($raw, true);
                if (!is_array($r) || (string)($r['id'] ?? '') === '' || !isset($r['from'], $r['subject'], $r['body'])
                    || !is_string($r['from']) || !is_string($r['subject']) || !is_string($r['body'])
                    || (string)($r['date'] ?? '') === '') {
                    $skipped++;
                    continue;
                }
                $emit([
                    'area_key' => $file,
                    'from_raw' => self::bytes((string)$r['from'], $n),
                    'to_raw' => self::bytes((string)($r['to'] ?? ''), $n),
                    'subject_raw' => self::bytes((string)$r['subject'], $n),
                    'body_raw' => self::bytes((string)$r['body'], $n),
                    'date' => $n->dateFromLocal(str_replace('T', ' ', (string)$r['date'])),
                    'id' => (string)$r['id'],
                    'reply_to' => isset($r['reply_to']) && $r['reply_to'] !== null && (string)$r['reply_to'] !== ''
                        ? (string)$r['reply_to'] : null,
                ]);
                $emitted++;
            }
            if (feof($fh)) {
                fclose($fh);
                $u++;
                $line = 0;
                continue;
            }
            fclose($fh);
            break;
        }
        return ['cursor' => ['u' => $u, 'line' => $line], 'done' => $u >= count($units), 'skipped' => $skipped];
    }

    /** JSON string -> source bytes for the normalizer (see class doc). */
    private static function bytes(string $s, Normalizer $n): string
    {
        if ($n->encoding() === 'utf-8') {
            return $s;
        }
        // Codepoints U+0000-U+00FF carry the original bytes; anything
        // higher has no byte to be and becomes ? for the clamp to count.
        $out = '';
        $len = mb_strlen($s, 'UTF-8');
        for ($i = 0; $i < $len; $i++) {
            $cp = mb_ord(mb_substr($s, $i, 1, 'UTF-8'), 'UTF-8');
            $out .= $cp <= 0xFF ? chr($cp) : '?';
        }
        return $out;
    }
}
