<?php

declare(strict_types=1);

namespace Atdt\Import;

use Atdt\Cp437;

/**
 * The one place every parser's output is normalized before staging
 * (SPEC-IMPORT §14). Parsers hand over raw bytes and raw dates; this
 * turns them into board-ready text (Unicode within the CP437
 * repertoire, pipes escaped, wrapped at 79) and UTC stamps. Rules are
 * applied here once, never per format.
 */
final class Normalizer
{
    public const ENCODINGS = ['cp437', 'utf-8', 'latin1', 'ascii', 'petscii'];

    /** Characters replaced with ? because CP437 has no home for them. */
    public int $substitutions = 0;
    /** Messages whose date could not be read. */
    public int $undated = 0;

    private \DateTimeZone $tz;
    private \DateTimeZone $utc;

    public function encoding(): string
    {
        return $this->encoding;
    }

    public function __construct(private string $encoding, string $tz)
    {
        $this->encoding = in_array($encoding, self::ENCODINGS, true) ? $encoding : 'cp437';
        try {
            $this->tz = new \DateTimeZone($tz === '' ? 'UTC' : $tz);
        } catch (\Throwable $e) {
            $this->tz = new \DateTimeZone('UTC');
        }
        $this->utc = new \DateTimeZone('UTC');
    }

    // ---- encoding (§14.1) -------------------------------------------------

    /** Decode source bytes to Unicode text inside the CP437 repertoire. */
    public function text(string $bytes): string
    {
        $u = match ($this->encoding) {
            'utf-8' => mb_check_encoding($bytes, 'UTF-8') ? $bytes
                : (string)mb_convert_encoding($bytes, 'UTF-8', 'ISO-8859-1'),
            'latin1' => (string)mb_convert_encoding($bytes, 'UTF-8', 'ISO-8859-1'),
            'ascii' => preg_replace('/[\x80-\xff]/', '?', $bytes) ?? $bytes,
            'petscii' => Petscii::toUnicode($bytes),
            default => Cp437::toUnicode($bytes),
        };
        // Clamp to the CP437 repertoire: a char with no CP437 home
        // becomes ?, counted, because a high count is the clearest
        // signal of a wrong encoding choice (§14.1).
        $out = '';
        $len = mb_strlen($u, 'UTF-8');
        for ($i = 0; $i < $len; $i++) {
            $ch = mb_substr($u, $i, 1, 'UTF-8');
            $cp = mb_ord($ch, 'UTF-8');
            if ($cp < 0x80 || $ch === "\n" || $ch === "\t") {
                $out .= $ch;
                continue;
            }
            $back = Cp437::fromUnicode($ch);
            if ($back === '?') {
                $this->substitutions++;
                $out .= '?';
            } else {
                $out .= $ch;
            }
        }
        return $out;
    }

    // ---- dates (§14.2) ----------------------------------------------------

    /** A unix timestamp that is really local wall time (JAM). */
    public function dateFromLocalUnix(int $ts): ?string
    {
        if ($ts <= 0) {
            return null;
        }
        // The u32 counts seconds as if the wall clock were UTC; rebuild
        // the wall time, then interpret it in the chosen zone.
        $wall = gmdate('Y-m-d H:i:s', $ts);
        return $this->dateFromLocal($wall);
    }

    /** A unix timestamp that is genuinely UTC (SMB carries its zone). */
    public function dateFromUnix(int $ts): ?string
    {
        if ($ts <= 0) {
            return null;
        }
        return gmdate('Y-m-d\TH:i:s\Z', $ts);
    }

    /** 'Y-m-d H:i:s'-ish local wall time in the chosen zone -> UTC. */
    public function dateFromLocal(string $s): ?string
    {
        $s = trim($s);
        if ($s === '') {
            return null;
        }
        foreach (['Y-m-d\TH:i:s', 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d'] as $f) {
            $d = \DateTimeImmutable::createFromFormat('!' . $f, $s, $this->tz);
            if ($d !== false && $d->format($f) === $s) {
                return $this->clampStamp($d);
            }
        }
        // ISO with an explicit offset: honor it over the chosen zone.
        try {
            $d = new \DateTimeImmutable($s);
            return $this->clampStamp($d);
        } catch (\Throwable $e) {
            return null;
        }
    }

    /** DOS packed date+time words (Squish SCOMBO). */
    public function dateFromDos(int $date, int $time): ?string
    {
        $day = $date & 0x1F;
        $mon = ($date >> 5) & 0x0F;
        $year = 1980 + (($date >> 9) & 0x7F);
        if ($day < 1 || $mon < 1 || $mon > 12) {
            return null;
        }
        $sec = ($time & 0x1F) * 2;
        $min = ($time >> 5) & 0x3F;
        $hour = ($time >> 11) & 0x1F;
        return $this->dateFromLocal(sprintf('%04d-%02d-%02d %02d:%02d:%02d', $year, $mon, $day, $hour, $min, min(59, $sec)));
    }

    /** 'MM-DD-YY' plus 'HH:MM' (Hudson, QWK). Two-digit years pivot at 79. */
    public function dateFromShort(string $date, string $time): ?string
    {
        if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/', trim($date), $m) !== 1) {
            return null;
        }
        $year = (int)$m[3];
        if ($year < 100) {
            $year += $year >= 79 ? 1900 : 2000;
        }
        $hm = preg_match('/^(\d{1,2}):(\d{2})/', trim($time), $t) === 1
            ? sprintf('%02d:%02d:00', (int)$t[1], (int)$t[2]) : '00:00:00';
        return $this->dateFromLocal(sprintf('%04d-%02d-%02d %s', $year, (int)$m[1], (int)$m[2], $hm));
    }

    /** FTS-1 'dd Mmm yy  HH:MM:SS' and friends. */
    public function dateFromFts(string $s): ?string
    {
        $s = trim($s);
        $months = ['jan' => 1, 'feb' => 2, 'mar' => 3, 'apr' => 4, 'may' => 5, 'jun' => 6,
            'jul' => 7, 'aug' => 8, 'sep' => 9, 'oct' => 10, 'nov' => 11, 'dec' => 12];
        if (preg_match('/(\d{1,2})\s+([A-Za-z]{3})\s+(\d{2,4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?/', $s, $m) !== 1) {
            return null;
        }
        $mon = $months[strtolower($m[2])] ?? 0;
        if ($mon === 0) {
            return null;
        }
        $year = (int)$m[3];
        if ($year < 100) {
            $year += $year >= 79 ? 1900 : 2000;
        }
        return $this->dateFromLocal(sprintf(
            '%04d-%02d-%02d %02d:%02d:%02d',
            $year,
            $mon,
            (int)$m[1],
            (int)$m[4],
            (int)$m[5],
            (int)($m[6] ?? 0)
        ));
    }

    private function clampStamp(\DateTimeImmutable $d): ?string
    {
        $y = (int)$d->format('Y');
        if ($y < 1970 || $y > 2100) {
            return null;
        }
        return $d->setTimezone($this->utc)->format('Y-m-d\TH:i:s\Z');
    }

    // ---- body cleanup (§14.3), applied in this order ----------------------

    public function body(string $bytes): string
    {
        $text = $this->text($bytes);
        // 1. Line endings: CR, LF, CRLF all become one break.
        $text = str_replace(["\r\n", "\r"], "\n", $text);
        $lines = explode("\n", $text);
        $out = [];
        foreach ($lines as $line) {
            // 2. FTN kludges: SOH-led lines and SEEN-BY were never visible.
            if ($line !== '' && ($line[0] === "\x01" || str_starts_with($line, 'SEEN-BY:'))) {
                continue;
            }
            // 3. The tearline and origin line stay: they were visible and
            //    they name the originating board. Part of the record.
            // 4. ANSI escapes would break the 80x25 canvas.
            $line = preg_replace('/\x1b\[[0-9;?]*[A-Za-z]|\x1b./', '', $line) ?? $line;
            // 5. Remaining controls except the break.
            $line = preg_replace('/[\x00-\x08\x0b-\x1f\x7f]/', '', $line) ?? $line;
            // 6. Tabs to 8-column stops.
            while (($p = mb_strpos($line, "\t")) !== false) {
                $pad = 8 - ($p % 8);
                $line = mb_substr($line, 0, $p) . str_repeat(' ', $pad) . mb_substr($line, $p + 1);
            }
            // 7. Literal pipes double so |NN in a 1987 message is not a color.
            $line = str_replace('|', '||', $line);
            // 8. Soft-wrap at 79, never clip.
            $line = rtrim($line);
            if (mb_strlen($line) <= 79) {
                $out[] = $line;
                continue;
            }
            while (mb_strlen($line) > 79) {
                $cut = mb_substr($line, 0, 79);
                $sp = mb_strrpos($cut, ' ');
                if ($sp === false || $sp < 1) {
                    $sp = 79;
                }
                $out[] = rtrim(mb_substr($line, 0, $sp));
                $line = ltrim(mb_substr($line, $sp));
            }
            $out[] = $line;
        }
        // 9. Trim trailing blank lines.
        while ($out !== [] && trim((string)end($out)) === '') {
            array_pop($out);
        }
        return implode("\n", $out);
    }

    // ---- subjects and handles (§14.4, §14.5) ------------------------------

    public function subject(string $bytes): string
    {
        $s = trim($this->text($bytes));
        $s = preg_replace('/[\x00-\x1f\x7f]/', '', $s) ?? $s;
        $s = str_replace('|', '||', $s);
        $s = mb_strimwidth($s, 0, 70);
        return $s === '' ? '(no subject)' : $s;
    }

    public function handle(string $bytes, string $fallback = 'Unknown'): string
    {
        $h = trim($this->text($bytes));
        $h = preg_replace('/[\x00-\x1f\x7f|]/', '', $h) ?? $h;
        $h = mb_strimwidth($h, 0, 30);
        return $h === '' ? $fallback : $h;
    }

    /** The To field: null means a public post (absent or ALL). */
    public function toHandle(string $bytes): ?string
    {
        $h = $this->handle($bytes, '');
        if ($h === '' || strcasecmp($h, 'ALL') === 0) {
            return null;
        }
        return $h;
    }
}
