<?php
/**
 * Niwa AI — Document parser (Phase 6 / feature/telegram-and-authonomy).
 *
 * Up through Phase 5, `attachment_documents[]` carried local paths to PDFs,
 * spreadsheets, and other office formats but no consumer ever read their
 * content — the agent only saw "the user attached invoice.pdf" and had to
 * guess at the rest. Phase 6 routes each document through a format-specific
 * extractor and surfaces the resulting text + structural metadata as
 * `document_excerpts[]`, capped at MAX_TEXT_CHARS so a single multi-MB PDF
 * cannot blow up the agent's context window.
 *
 * Format routing (priority: declared `content_type` first, then file
 * extension, then magic-bytes hint):
 *
 *   PDF      → smalot/pdfparser    (LGPL-3.0)
 *   XLSX     → openspout/openspout (MIT/Apache-2.0)
 *   ODS      → openspout/openspout
 *   DOCX     → native ZipArchive + DOMDocument (no composer dep)
 *   CSV      → native fgetcsv with delimiter auto-detect
 *   JSON     → native json_decode + pretty print
 *   MD/TXT   → file_get_contents (with encoding cleanup)
 *   HTML     → strip_tags pass
 *   ZIP      → file listing (plus SKILL.md unwrap if it's a Niwa skill bundle)
 *
 * Parsers degrade gracefully when the optional Composer libraries are not
 * installed — the parser returns `success: false` with a clear error rather
 * than fatalling, so the rest of the pipeline keeps working.
 *
 * @package Niwa_AI
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Niwa_AI_Admin_Autonomy_Document_Parser {

    const MAX_TEXT_CHARS  = 50000;
    const MAX_TABLE_ROWS  = 1000;
    const MAX_ARCHIVE_ENTRIES = 2000;
    const MAX_ARCHIVE_UNCOMPRESSED_BYTES = 67108864;
    const MAX_ARCHIVE_ENTRY_BYTES = 16777216;
    const MAX_ARCHIVE_COMPRESSION_RATIO = 200;
    const MAX_DOCX_XML_BYTES = 4194304;
    const MAX_DOCX_TEXT_BYTES = 1048576;
    const TRUNCATION_TAG  = "\n\n[…document truncated; original was longer]";

    /**
     * Return true when the parser can extract text from this format.
     *
     * @param string $content_type MIME type (Phase 3 fetcher result).
     * @param string $filename     Original filename (for extension fallback).
     */
    public static function is_supported( $content_type, $filename = '' ) {
        return null !== self::resolve_format( $content_type, $filename );
    }

    /**
     * Parse a document. Always returns a structured array; `success=false`
     * means we recognized the format but could not extract — caller should
     * surface the file but skip the extracted text.
     *
     * @param string $path         Local file path.
     * @param string $content_type Declared MIME type (may be empty).
     * @param string $filename     Original filename for routing fallback.
     * @return array {
     *     success:   bool,
     *     text:      string,
     *     metadata:  array,
     *     structure: array,
     *     truncated: bool,
     *     format:    string,
     *     error?:    string
     * }
     */
    public static function parse_document( $path, $content_type = '', $filename = '' ) {
        $path = (string) $path;
        if ( '' === $path || ! is_file( $path ) || ! is_readable( $path ) ) {
            return self::error_result( 'unknown', 'Document file not found or unreadable: ' . $path );
        }

        $format = self::resolve_format( (string) $content_type, (string) $filename );
        if ( null === $format ) {
            return self::error_result( 'unknown', 'Unsupported document format. content_type=' . $content_type . ' filename=' . $filename );
        }

        if ( in_array( $format, array( 'xlsx', 'ods', 'docx', 'zip' ), true ) ) {
            $archive_error = self::archive_preflight_error( $path, $format );
            if ( null !== $archive_error ) {
                return $archive_error;
            }
        }

        switch ( $format ) {
            case 'pdf':
                return self::parse_pdf( $path );
            case 'xlsx':
            case 'ods':
                return self::parse_spreadsheet_via_openspout( $path, $format );
            case 'docx':
                return self::parse_docx( $path );
            case 'csv':
                return self::parse_csv( $path );
            case 'json':
                return self::parse_json( $path );
            case 'html':
                return self::parse_html( $path );
            case 'zip':
                return self::parse_zip( $path );
            case 'md':
            case 'txt':
            default:
                return self::parse_plaintext( $path, $format );
        }
    }

    /* ───── Format routing ───────────────────────────────────────────── */

    private static function resolve_format( $content_type, $filename ) {
        $content_type = strtolower( trim( (string) $content_type ) );
        $ext          = strtolower( pathinfo( (string) $filename, PATHINFO_EXTENSION ) );

        // Content-type wins when present.
        if ( 'application/pdf' === $content_type ) {
            return 'pdf';
        }
        if ( 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' === $content_type
            || 'application/vnd.ms-excel' === $content_type ) {
            return 'xlsx';
        }
        if ( 'application/vnd.oasis.opendocument.spreadsheet' === $content_type ) {
            return 'ods';
        }
        if ( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' === $content_type ) {
            return 'docx';
        }
        if ( 'text/csv' === $content_type ) {
            return 'csv';
        }
        if ( 'application/json' === $content_type ) {
            return 'json';
        }
        if ( 'text/html' === $content_type ) {
            return 'html';
        }
        if ( 'application/zip' === $content_type
            || 'application/x-zip-compressed' === $content_type ) {
            return 'zip';
        }
        if ( 'text/markdown' === $content_type || 'text/x-markdown' === $content_type ) {
            return 'md';
        }
        if ( 0 === strpos( $content_type, 'text/' ) ) {
            return 'txt';
        }

        // Extension fallback (Telegram often gives octet-stream MIME).
        switch ( $ext ) {
            case 'pdf':
                return 'pdf';
            case 'xlsx':
            case 'xls':
                return 'xlsx';
            case 'ods':
                return 'ods';
            case 'docx':
                return 'docx';
            case 'csv':
            case 'tsv':
                return 'csv';
            case 'json':
                return 'json';
            case 'html':
            case 'htm':
                return 'html';
            case 'md':
            case 'markdown':
                return 'md';
            case 'txt':
            case 'log':
                return 'txt';
            case 'zip':
            case 'skill':
                return 'zip';
        }

        return null;
    }

    /* ───── Format parsers ───────────────────────────────────────────── */

    private static function parse_pdf( $path ) {
        if ( ! class_exists( '\\Smalot\\PdfParser\\Parser' ) ) {
            return self::error_result( 'pdf', 'smalot/pdfparser is not installed; run composer install in the plugin directory.' );
        }

        try {
            $parser   = new \Smalot\PdfParser\Parser();
            $document = $parser->parseFile( $path );
            $raw_text = (string) $document->getText();
            $details  = $document->getDetails();

            $page_count = 0;
            if ( method_exists( $document, 'getPages' ) ) {
                $pages       = $document->getPages();
                $page_count  = is_array( $pages ) ? count( $pages ) : 0;
            }
            if ( 0 === $page_count && isset( $details['Pages'] ) ) {
                $page_count = intval( $details['Pages'] );
            }

            $text = self::normalize_whitespace( $raw_text );
            list( $text, $truncated ) = self::truncate( $text );

            return array(
                'success'   => true,
                'format'    => 'pdf',
                'text'      => $text,
                'metadata'  => array(
                    'page_count' => $page_count,
                    'title'      => isset( $details['Title'] ) ? (string) $details['Title'] : '',
                    'author'     => isset( $details['Author'] ) ? (string) $details['Author'] : '',
                ),
                'structure' => array(),
                'truncated' => $truncated,
            );
        } catch ( \Throwable $e ) {
            return self::error_result( 'pdf', 'PDF parse failed: ' . $e->getMessage() );
        }
    }

    private static function parse_spreadsheet_via_openspout( $path, $format ) {
        $reader_class = 'xlsx' === $format
            ? '\\OpenSpout\\Reader\\XLSX\\Reader'
            : '\\OpenSpout\\Reader\\ODS\\Reader';
        if ( ! class_exists( $reader_class ) ) {
            return self::error_result( $format, 'openspout/openspout is not installed; run composer install in the plugin directory.' );
        }

        try {
            $reader = new $reader_class();
            $reader->open( $path );

            $sheet_blocks = array();
            $sheet_names  = array();
            $row_total    = 0;
            $truncated    = false;

            foreach ( $reader->getSheetIterator() as $sheet ) {
                if ( $row_total >= self::MAX_TABLE_ROWS ) {
                    $truncated = true;
                    break;
                }
                $name = method_exists( $sheet, 'getName' ) ? (string) $sheet->getName() : 'Sheet';
                $sheet_names[] = $name;

                $rows           = array();
                $rows_remaining = self::MAX_TABLE_ROWS - $row_total;
                foreach ( $sheet->getRowIterator() as $row ) {
                    if ( count( $rows ) >= $rows_remaining ) {
                        $truncated = true;
                        break;
                    }
                    $cells = array();
                    foreach ( $row->getCells() as $cell ) {
                        $cells[] = self::cell_value_to_string( $cell );
                    }
                    $rows[] = $cells;
                }
                $row_total += count( $rows );

                if ( ! empty( $rows ) ) {
                    $sheet_blocks[] = '## ' . $name . "\n\n" . self::rows_to_markdown_table( $rows );
                }
            }

            $reader->close();

            $text = implode( "\n\n", $sheet_blocks );
            list( $text, $extra_trunc ) = self::truncate( $text );
            if ( $extra_trunc ) {
                $truncated = true;
            }

            return array(
                'success'   => true,
                'format'    => $format,
                'text'      => $text,
                'metadata'  => array(
                    'sheet_names' => $sheet_names,
                    'sheet_count' => count( $sheet_names ),
                    'row_total'   => $row_total,
                ),
                'structure' => array(),
                'truncated' => $truncated,
            );
        } catch ( \Throwable $e ) {
            return self::error_result( $format, 'Spreadsheet parse failed: ' . $e->getMessage() );
        }
    }

    /**
     * Extract text from a DOCX file using native ZipArchive + DOMDocument.
     *
     * DOCX is a ZIP container; the body XML lives at `word/document.xml`.
     * We pull every `<w:t>` text run and treat each `<w:p>` paragraph as a
     * line break boundary. Optional headers/footers (`word/header*.xml`,
     * `word/footer*.xml`) are prepended/appended when present.
     *
     * No composer dep required — relies on PHP's built-in ext-zip + ext-dom.
     */
    private static function parse_docx( $path ) {
        if ( ! class_exists( 'ZipArchive' ) ) {
            return self::error_result( 'docx', 'PHP ZipArchive extension is not available.' );
        }
        if ( ! class_exists( 'DOMDocument' ) ) {
            return self::error_result( 'docx', 'PHP DOM extension is not available.' );
        }

        $zip    = new \ZipArchive();
        $opened = $zip->open( $path );
        if ( true !== $opened ) {
            return self::error_result( 'docx', 'Cannot open DOCX archive (code ' . intval( $opened ) . ').' );
        }

        $document_xml = $zip->getFromName( 'word/document.xml' );
        if ( false === $document_xml || '' === $document_xml ) {
            $zip->close();
            return self::error_result( 'docx', 'DOCX missing word/document.xml — not a valid Office Open XML document.' );
        }

        $body_result = self::extract_docx_body_text( $document_xml );
        if ( null === $body_result ) {
            $zip->close();
            return self::error_result( 'docx', 'DOCX body XML failed to parse.' );
        }
        list( $body_text, $paragraph_count ) = $body_result;

        $header_text = self::extract_docx_part_text( $zip, 'header' );
        $footer_text = self::extract_docx_part_text( $zip, 'footer' );
		if ( null === $header_text || null === $footer_text ) {
			$zip->close();
			return self::error_result( 'docx', 'DOCX header/footer XML failed security validation.' );
		}

        $zip->close();

        $parts = array();
        if ( '' !== $header_text ) {
            $parts[] = $header_text;
        }
        if ( '' !== $body_text ) {
            $parts[] = $body_text;
        }
        if ( '' !== $footer_text ) {
            $parts[] = $footer_text;
        }
        $combined = implode( "\n\n", $parts );

        $combined = self::normalize_whitespace( $combined );
        list( $text, $truncated ) = self::truncate( $combined );

        return array(
            'success'   => true,
            'format'    => 'docx',
            'text'      => $text,
            'metadata'  => array(
                'paragraph_count' => $paragraph_count,
                'has_header'      => '' !== $header_text,
                'has_footer'      => '' !== $footer_text,
            ),
            'structure' => array(),
            'truncated' => $truncated,
        );
    }

    /**
     * Parse DOCX body XML and return [text, paragraph_count] or null on parse error.
     *
     * Walks every `<w:p>` paragraph; within each, concatenates all `<w:t>`
     * text-run contents (in document order). Paragraph boundaries become
     * `\n\n` in the output so prose stays readable.
     */
    private static function extract_docx_body_text( $xml ) {
        $xml = (string) $xml;
        if ( '' === $xml ) {
            return array( '', 0 );
        }

        $dom = self::load_safe_docx_xml( $xml );
        if ( null === $dom ) {
            return null;
        }

        $w_ns = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
        $paragraphs = $dom->getElementsByTagNameNS( $w_ns, 'p' );

        $lines           = array();
        $paragraph_count = 0;
        foreach ( $paragraphs as $p ) {
            $paragraph_count++;
            $line = '';
            $texts = $p->getElementsByTagNameNS( $w_ns, 't' );
            foreach ( $texts as $t ) {
                $line .= $t->nodeValue;
            }
            // Honor explicit `<w:br/>` line breaks inside a paragraph.
            $brs = $p->getElementsByTagNameNS( $w_ns, 'br' );
            if ( $brs->length > 0 && '' === $line ) {
                $line = '';
            }
            $lines[] = $line;
        }

        // Empty document.xml (no <w:p>) — fall back to all <w:t> joined.
        if ( 0 === $paragraph_count ) {
            $texts = $dom->getElementsByTagNameNS( $w_ns, 't' );
            $buf = '';
            foreach ( $texts as $t ) {
                $buf .= $t->nodeValue;
            }
            return array( $buf, 0 );
        }

        return array( implode( "\n\n", $lines ), $paragraph_count );
    }

    /**
     * Concatenate text from every header*.xml or footer*.xml part in the DOCX.
     *
     * @param \ZipArchive $zip   Already-opened archive.
     * @param string      $kind  Either 'header' or 'footer'.
     */
    private static function extract_docx_part_text( $zip, $kind ) {
        $kind = (string) $kind;
        if ( 'header' !== $kind && 'footer' !== $kind ) {
            return '';
        }

        $w_ns       = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
        $collected  = array();
        $file_count = $zip->numFiles;
        for ( $i = 0; $i < $file_count; $i++ ) {
            $name = (string) $zip->getNameIndex( $i );
            if ( '' === $name ) {
                continue;
            }
            // Match `word/header.xml`, `word/header1.xml`, etc.
            if ( ! preg_match( '#^word/' . preg_quote( $kind, '#' ) . '\d*\.xml$#', $name ) ) {
                continue;
            }
            $contents = $zip->getFromIndex( $i );
            if ( ! is_string( $contents ) || '' === $contents ) {
                continue;
            }
            $dom = self::load_safe_docx_xml( $contents );
            if ( null === $dom ) {
                return null;
            }
            $texts = $dom->getElementsByTagNameNS( $w_ns, 't' );
            $buf = '';
            foreach ( $texts as $t ) {
                $buf .= $t->nodeValue;
            }
            $buf = trim( $buf );
            if ( '' !== $buf ) {
                $collected[] = $buf;
            }
        }
        return implode( "\n", $collected );
    }

    private static function load_safe_docx_xml( $xml ) {
        $xml = (string) $xml;
        if (
            '' === $xml
            || strlen( $xml ) > self::MAX_DOCX_XML_BYTES
            || false !== stripos( $xml, '<!DOCTYPE' )
            || false !== stripos( $xml, '<!ENTITY' )
        ) {
            return null;
        }

        $dom                     = new \DOMDocument();
        $dom->resolveExternals   = false;
        $dom->substituteEntities = false;
        $dom->validateOnParse    = false;
        $prev                    = libxml_use_internal_errors( true );
        $loaded                  = $dom->loadXML( $xml, LIBXML_NONET | LIBXML_COMPACT );
        libxml_clear_errors();
        libxml_use_internal_errors( $prev );
        if ( ! $loaded || strlen( (string) $dom->textContent ) > self::MAX_DOCX_TEXT_BYTES ) {
            return null;
        }

        return $dom;
    }

    private static function cell_value_to_string( $cell ) {
        // OpenSpout v4 cells expose getValue(). The value can be string, int,
        // float, bool, DateTime, or null — normalize to a printable string.
        if ( ! is_object( $cell ) || ! method_exists( $cell, 'getValue' ) ) {
            return '';
        }
        $value = $cell->getValue();
        if ( null === $value ) {
            return '';
        }
        if ( is_bool( $value ) ) {
            return $value ? 'true' : 'false';
        }
        if ( $value instanceof \DateTimeInterface ) {
            return $value->format( 'Y-m-d H:i:s' );
        }
        if ( is_scalar( $value ) ) {
            return (string) $value;
        }
        return '';
    }

    private static function rows_to_markdown_table( $rows ) {
        if ( empty( $rows ) ) {
            return '';
        }
        $col_count = 0;
        foreach ( $rows as $r ) {
            $col_count = max( $col_count, count( $r ) );
        }
        $col_count = max( 1, $col_count );

        $lines = array();
        $first = true;
        foreach ( $rows as $row ) {
            // Pad rows to the widest column count so the markdown table is rectangular.
            $padded = array_pad( $row, $col_count, '' );
            $cells  = array_map( array( __CLASS__, 'escape_markdown_cell' ), $padded );
            $lines[] = '| ' . implode( ' | ', $cells ) . ' |';
            if ( $first ) {
                $lines[] = '| ' . implode( ' | ', array_fill( 0, $col_count, '---' ) ) . ' |';
                $first = false;
            }
        }
        return implode( "\n", $lines );
    }

    private static function escape_markdown_cell( $value ) {
        $v = (string) $value;
        $v = str_replace( array( "\r\n", "\r", "\n", '|' ), array( ' ', ' ', ' ', '\\|' ), $v );
        return trim( $v );
    }

    private static function parse_csv( $path ) {
        $delimiter = self::detect_csv_delimiter( $path );
        $rows = array();
        $truncated = false;
        $fh = @fopen( $path, 'rb' );
        if ( false === $fh ) {
            return self::error_result( 'csv', 'Cannot open CSV file.' );
        }

        // Skip UTF-8 BOM if present.
        $bom = fread( $fh, 3 );
        if ( "\xEF\xBB\xBF" !== $bom ) {
            rewind( $fh );
        }

        while ( false !== ( $row = fgetcsv( $fh, 0, $delimiter ) ) ) {
            if ( count( $rows ) >= self::MAX_TABLE_ROWS ) {
                $truncated = true;
                break;
            }
            $rows[] = $row;
        }
        fclose( $fh );

        $text = self::rows_to_markdown_table( $rows );
        list( $text, $extra ) = self::truncate( $text );
        if ( $extra ) {
            $truncated = true;
        }

        return array(
            'success'   => true,
            'format'    => 'csv',
            'text'      => $text,
            'metadata'  => array(
                'row_count' => count( $rows ),
                'delimiter' => $delimiter,
            ),
            'structure' => array(),
            'truncated' => $truncated,
        );
    }

    private static function detect_csv_delimiter( $path ) {
        $fh = @fopen( $path, 'rb' );
        if ( false === $fh ) {
            return ',';
        }
        $sample = fread( $fh, 4096 );
        fclose( $fh );

        $candidates = array( ',', ';', "\t", '|' );
        $best       = ',';
        $best_count = 0;
        foreach ( $candidates as $d ) {
            $count = substr_count( (string) $sample, $d );
            if ( $count > $best_count ) {
                $best_count = $count;
                $best       = $d;
            }
        }
        return $best;
    }

    private static function parse_json( $path ) {
        $contents = @file_get_contents( $path );
        if ( false === $contents ) {
            return self::error_result( 'json', 'Cannot read JSON file.' );
        }
        $decoded = json_decode( $contents, true );
        if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
            return self::error_result( 'json', 'JSON decode failed: ' . json_last_error_msg() );
        }
        $pretty = json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
        list( $text, $truncated ) = self::truncate( (string) $pretty );

        return array(
            'success'   => true,
            'format'    => 'json',
            'text'      => $text,
            'metadata'  => array(
                'top_level_type' => is_array( $decoded ) && array_keys( $decoded ) === range( 0, count( $decoded ) - 1 ) ? 'list' : ( is_array( $decoded ) ? 'object' : gettype( $decoded ) ),
                'size_bytes'     => strlen( $contents ),
            ),
            'structure' => array(),
            'truncated' => $truncated,
        );
    }

    private static function parse_html( $path ) {
        $contents = @file_get_contents( $path );
        if ( false === $contents ) {
            return self::error_result( 'html', 'Cannot read HTML file.' );
        }
        $stripped = trim( strip_tags( (string) $contents ) );
        $stripped = self::normalize_whitespace( $stripped );
        list( $text, $truncated ) = self::truncate( $stripped );

        return array(
            'success'   => true,
            'format'    => 'html',
            'text'      => $text,
            'metadata'  => array( 'size_bytes' => strlen( $contents ) ),
            'structure' => array(),
            'truncated' => $truncated,
        );
    }

    private static function parse_plaintext( $path, $format ) {
        $contents = @file_get_contents( $path );
        if ( false === $contents ) {
            return self::error_result( $format, 'Cannot read text file.' );
        }
        $text = self::normalize_whitespace( (string) $contents );
        list( $text, $truncated ) = self::truncate( $text );

        return array(
            'success'   => true,
            'format'    => $format,
            'text'      => $text,
            'metadata'  => array( 'size_bytes' => strlen( $contents ) ),
            'structure' => array(),
            'truncated' => $truncated,
        );
    }

    private static function parse_zip( $path ) {
        if ( ! class_exists( 'ZipArchive' ) ) {
            return self::error_result( 'zip', 'PHP ZipArchive extension is not available.' );
        }

        $zip = new \ZipArchive();
        $opened = $zip->open( $path );
        if ( true !== $opened ) {
            return self::error_result( 'zip', 'Cannot open ZIP archive (code ' . intval( $opened ) . ').' );
        }

        $files       = array();
        $skill_md    = '';
        for ( $i = 0; $i < $zip->numFiles; $i++ ) {
            $stat = $zip->statIndex( $i );
            if ( ! $stat ) {
                continue;
            }
            $files[] = array(
                'name' => (string) $stat['name'],
                'size' => intval( $stat['size'] ),
            );
            // Niwa skill bundles ship a SKILL.md at the root; surface it directly.
            if ( '' === $skill_md && in_array( strtolower( basename( (string) $stat['name'] ) ), array( 'skill.md' ), true ) ) {
                $contents = $zip->getFromIndex( $i );
                if ( is_string( $contents ) ) {
                    $skill_md = $contents;
                }
            }
            if ( count( $files ) >= 200 ) {
                break;
            }
        }
        $zip->close();

        $listing = "## Archive contents\n\n";
        foreach ( $files as $f ) {
            $listing .= '- ' . $f['name'] . ' (' . $f['size'] . ' bytes)' . "\n";
        }

        $text = '' !== $skill_md
            ? $listing . "\n## SKILL.md\n\n" . trim( $skill_md )
            : $listing;
        list( $text, $truncated ) = self::truncate( $text );

        return array(
            'success'   => true,
            'format'    => 'zip',
            'text'      => $text,
            'metadata'  => array(
                'file_count' => count( $files ),
                'has_skill'  => '' !== $skill_md,
            ),
            'structure' => $files,
            'truncated' => $truncated,
        );
    }

    /* ───── Helpers ──────────────────────────────────────────────────── */

    private static function archive_preflight_error( $path, $format ) {
        if ( ! class_exists( 'ZipArchive' ) ) {
            return null;
        }

        $zip = new \ZipArchive();
        if ( true !== $zip->open( $path ) ) {
            return null;
        }

        if ( $zip->numFiles > self::MAX_ARCHIVE_ENTRIES ) {
            $zip->close();
            return self::error_result( $format, 'Archive contains too many entries.' );
        }

        $total = 0;
        for ( $i = 0; $i < $zip->numFiles; $i++ ) {
            $stat = $zip->statIndex( $i );
            if ( ! is_array( $stat ) ) {
                continue;
            }

            $size      = max( 0, intval( $stat['size'] ?? 0 ) );
            $comp_size = max( 0, intval( $stat['comp_size'] ?? 0 ) );
            $total    += $size;
            if (
                $size > self::MAX_ARCHIVE_ENTRY_BYTES
                || $total > self::MAX_ARCHIVE_UNCOMPRESSED_BYTES
                || ( $size > 1048576 && $comp_size > 0 && ( $size / $comp_size ) > self::MAX_ARCHIVE_COMPRESSION_RATIO )
            ) {
                $zip->close();
                return self::error_result( $format, 'Archive expansion exceeds the safe parser limit.' );
            }
        }

        $zip->close();
        return null;
    }

    private static function normalize_whitespace( $text ) {
        $text = (string) $text;
        // Normalize CRLF/CR to LF so downstream consumers don't have to.
        $text = str_replace( array( "\r\n", "\r" ), "\n", $text );
        // Collapse runs of >2 newlines to exactly 2 so the agent doesn't waste
        // tokens on whitespace.
        $text = preg_replace( '/\n{3,}/', "\n\n", $text );
        // Trim trailing whitespace on each line.
        $text = preg_replace( '/[ \t]+\n/', "\n", $text );
        return trim( (string) $text );
    }

    /**
     * Truncate text safely at MAX_TEXT_CHARS. Returns [text, truncated_bool].
     */
    private static function truncate( $text ) {
        $len = function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );
        if ( $len <= self::MAX_TEXT_CHARS ) {
            return array( $text, false );
        }
        $cut = function_exists( 'mb_substr' )
            ? mb_substr( $text, 0, self::MAX_TEXT_CHARS - mb_strlen( self::TRUNCATION_TAG, 'UTF-8' ), 'UTF-8' )
            : substr( $text, 0, self::MAX_TEXT_CHARS - strlen( self::TRUNCATION_TAG ) );
        return array( $cut . self::TRUNCATION_TAG, true );
    }

    private static function error_result( $format, $error ) {
        return array(
            'success'   => false,
            'format'    => (string) $format,
            'text'      => '',
            'metadata'  => array(),
            'structure' => array(),
            'truncated' => false,
            'error'     => (string) $error,
        );
    }
}
