<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Log;

use DateTimeImmutable;
use Exception;
use SplFileObject;
use Throwable;

/**
 * `jtllogs/auth.log` — the admin-login log, and the only file source in this layer.
 *
 * It exists only when `admin_login_logger_mode` includes `AdminLoginConfig::CONFIG_FILE`
 * (`Services\Factory.php:108-111`); when it does not, the file is simply absent and that is a
 * configuration fact, not a failure — {@see LogReader} turns it into `available: false`.
 *
 * Only the last {@see TAIL_BYTES} are ever read, by seeking to the end and reading forward from
 * there. `file_get_contents()` on a log nobody rotates would pull an unbounded file into memory on
 * a request an LLM triggered — the one failure mode that takes the whole shop down rather than
 * just this tool.
 */
final class AuthLogFile
{
    /** ~64 KB is a few thousand login lines: far more than any answer returns, cheap to read. */
    public const TAIL_BYTES = 65536;

    /** Monolog's LineFormatter default: `[datetime] channel.LEVEL: message`. */
    private const LINE = '/^\[([^\]]+)\]\s*([^\s.]+)\.([A-Z]+):\s*(.*)$/s';

    /** Monolog level names -> the ints {@see LogLevel::fromMonolog()} understands. */
    private const LEVEL_NAMES = [
        'DEBUG'     => 100,
        'INFO'      => 200,
        'NOTICE'    => 250,
        'WARNING'   => 300,
        'ERROR'     => 400,
        'CRITICAL'  => 500,
        'ALERT'     => 550,
        'EMERGENCY' => 600,
    ];

    public function __construct(
        private readonly string $path,
        private readonly int $tailBytes = self::TAIL_BYTES,
    ) {
    }

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

    public function exists(): bool
    {
        return \is_file($this->path) && \is_readable($this->path);
    }

    /**
     * The tail, newest line first, best-effort parsed. A line that does not match the formatter
     * still comes back with its `raw` text and a null `ts` — an unparsable line in a security log
     * is exactly the line you do not want silently dropped.
     *
     * @return list<array{ts:?DateTimeImmutable,raw:string,message:string,level:?string,channel:?string}>
     */
    public function tail(): array
    {
        $buffer = $this->readTail();
        if ($buffer === '') {
            return [];
        }

        $lines = \array_reverse(\preg_split('/\R/', $buffer) ?: []);
        $out   = [];
        foreach ($lines as $line) {
            $raw = \rtrim($line);
            if ($raw === '') {
                continue;
            }
            $out[] = $this->parse($raw);
        }

        return $out;
    }

    /** True when the file was longer than the tail we read, so the caller can say so. */
    public function truncatedToTail(): bool
    {
        return $this->exists() && (int)(\filesize($this->path) ?: 0) > $this->tailBytes;
    }

    private function readTail(): string
    {
        try {
            $file = new SplFileObject($this->path, 'rb');
            $file->fseek(0, \SEEK_END);
            $size  = (int)$file->ftell();
            $start = \max(0, $size - $this->tailBytes);
            if ($size === 0) {
                return '';
            }
            $file->fseek($start);
            $buffer = (string)$file->fread($size - $start);
        } catch (Throwable) {
            // A log we cannot open is reported as an empty tail rather than as an exception: the
            // caller asked "what happened", not "is the filesystem healthy".
            return '';
        }

        if ($start > 0) {
            // The seek almost certainly landed mid-line; that fragment would parse as a bogus entry.
            $newline = \strpos($buffer, "\n");
            $buffer  = $newline === false ? '' : \substr($buffer, $newline + 1);
        }

        return $buffer;
    }

    /**
     * @return array{ts:?DateTimeImmutable,raw:string,message:string,level:?string,channel:?string}
     */
    private function parse(string $raw): array
    {
        if (\preg_match(self::LINE, $raw, $match) !== 1) {
            return ['ts' => null, 'raw' => $raw, 'message' => $raw, 'level' => null, 'channel' => null];
        }

        $monolog = self::LEVEL_NAMES[\mb_convert_case($match[3], \MB_CASE_UPPER)] ?? null;

        return [
            'ts'  => self::timestamp($match[1]),
            'raw' => $raw,
            // The message without the `[date] channel.LEVEL:` prefix — grouping on the raw line
            // would put every entry in its own bucket, since the timestamp is unique per line.
            'message' => $match[4],
            'level'   => $monolog === null ? null : LogLevel::fromMonolog($monolog),
            'channel' => $match[2],
        ];
    }

    private static function timestamp(string $raw): ?DateTimeImmutable
    {
        try {
            return new DateTimeImmutable($raw);
        } catch (Exception) {
            return null; // best-effort by contract; the raw line still carries whatever it said
        }
    }
}
