<?php
/**
 * DokuWiki Plugin docmaster (Helper Component)
 *
 * Operaciones de base de datos y parsers para el Listado Maestro de Documentos.
 * Gestiona documentos, formatos, versiones y relaciones doc-formato.
 *
 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
 * @author  SIE Systems <soporte@systemsie.site>
 */

class helper_plugin_docmaster extends DokuWiki_Plugin
{
    /** @var helper_plugin_sqlite */
    protected $sqlite = null;

    /** @var bool */
    protected $dbInitialized = false;

    /** @var array Cache de namespaces calificados */
    protected $qualifiedNS = null;

    /** @var array Cache de rutas excluidas */
    protected $excludedPaths = null;

    // =========================================================================
    //  INICIALIZACIÓN DB
    // =========================================================================

    /**
     * Obtiene la conexión a la base de datos SQLite (lazy init)
     *
     * @return helper_plugin_sqlite|null
     */
    public function getDB()
    {
        if ($this->dbInitialized) {
            return $this->sqlite;
        }

        $this->dbInitialized = true;

        /** @var helper_plugin_sqlite $sqlite */
        $sqlite = plugin_load('helper', 'sqlite');
        if (!$sqlite) {
            msg('El plugin SQLite es requerido para docmaster', -1);
            return null;
        }

        if (!$sqlite->init('docmaster', DOKU_PLUGIN . 'docmaster/db/')) {
            return null;
        }

        $this->sqlite = $sqlite;
        return $this->sqlite;
    }

    // =========================================================================
    //  VERIFICACIÓN DE NAMESPACE
    // =========================================================================

    /**
     * Verifica si un page_id pertenece a un namespace calificado
     *
     * @param string $pageId Ej: 'compartidos:acciones_correctivas'
     * @return bool
     */
    public function isQualifiedNamespace($pageId)
    {
        if ($this->qualifiedNS === null) {
            $raw = $this->getConf('qualified_namespaces');
            $this->qualifiedNS = array_map('trim', explode(',', $raw));
            $this->qualifiedNS = array_filter($this->qualifiedNS);
        }

        // Obtener el namespace raíz del page_id
        $ns = getNS($pageId);
        if (empty($ns)) return false;

        // El namespace raíz es el primer segmento
        $parts = explode(':', $ns);
        $rootNS = $parts[0];

        return in_array($rootNS, $this->qualifiedNS);
    }

    /**
     * Verifica si un namespace está excluido del escaneo.
     *
     * Compara el namespace contra la lista de rutas excluidas.
     * Ejemplo: si excluded_paths = 'operativo:descripciones_operativas',
     * entonces 'operativo:descripciones_operativas' y cualquier sub-namespace
     * como 'operativo:descripciones_operativas:sub' serán excluidos.
     *
     * @param string $ns Namespace a verificar (ej: 'operativo:descripciones_operativas')
     * @return bool
     */
    public function isExcludedPath($ns)
    {
        if ($this->excludedPaths === null) {
            $raw = $this->getConf('excluded_paths');
            $this->excludedPaths = array_map('trim', explode(',', $raw));
            $this->excludedPaths = array_filter($this->excludedPaths);
        }

        foreach ($this->excludedPaths as $excluded) {
            // Coincidencia exacta o es sub-namespace
            if ($ns === $excluded || strpos($ns, $excluded . ':') === 0) {
                return true;
            }
        }

        return false;
    }

    // =========================================================================
    //  PARSERS DE CONTENIDO
    // =========================================================================

    /**
     * Parsea el encabezado visible del documento para extraer metadatos.
     *
     * La tabla de encabezado siempre tiene la misma estructura:
     *   | Logo | TÍTULO DEL DOCUMENTO | Info/Código/Versión/Vigencia |
     *   | :::  | :::                  | CODIGO:xxx                   |
     *   | :::  | :::                  | VERSION:x                    |
     *   | :::  | :::                  | VIGENCIA:dd/mm/yyyy          |
     *
     * El título más confiable está siempre en <hidden>===== TÍTULO =====</hidden>
     *
     * CÓDIGO, VERSIÓN y VIGENCIA se buscan por palabra clave en todo el contenido.
     *
     * NOTA: Todos los regex usan flag 'u' (Unicode) para manejar
     * correctamente caracteres UTF-8 acentuados (Ó, É, Í, etc.)
     *
     * @param string $content  Contenido wiki crudo de la pagina
     * @param string $pageId   ID de la página (para fallback de título)
     * @return array ['title' => string, 'code' => string, 'version' => int, 'validity_date' => string, 'area' => string]
     */
    public function parseHeader($content, $pageId = '')
    {
        $result = [
            'title'         => '',
            'code'          => '',
            'version'       => 0,
            'validity_date' => '',
            'area'          => '',
        ];

        // =====================================================================
        //  TÍTULO - buscar en orden de confiabilidad
        // =====================================================================

        // Prioridad 1: <hidden>===== TÍTULO =====</hidden>
        // Siempre presente y contiene el nombre real del documento
        if (preg_match('/<hidden>\s*={2,}\s*(.+?)\s*={2,}\s*<\/hidden>/isu', $content, $m)) {
            $result['title'] = trim($m[1]);
        }

        // Prioridad 2: ^ NOMBRE:TÍTULO | (patrón explícito con etiqueta NOMBRE)
        if (empty($result['title'])) {
            if (preg_match('/\^\s*NOMBRE\s*:\s*(.+?)\s*\|/imu', $content, $m)) {
                $result['title'] = trim($m[1]);
            }
        }

        // Prioridad 3: título en la celda ^ de la tabla de encabezado
        // Busca ^ TEXTO | donde TEXTO no sea :::, logo, ni campos de metadatos
        if (empty($result['title'])) {
            if (preg_match_all('/\^\s*(.+?)\s*\|/mu', $content, $matches)) {
                foreach ($matches[1] as $candidate) {
                    $candidate = trim($candidate);
                    if (empty($candidate)) continue;
                    // Ignorar: continuaciones, logos, etiqueta NOMBRE, campos de metadatos
                    if (preg_match('/^(:::|logo|nombre|informaci|c[oó]digo|versi|vigencia)/iu', $candidate)) continue;
                    $result['title'] = $candidate;
                    break;
                }
            }
        }

        // Prioridad 4: título en celda | TEXTO | (variación sin ^)
        if (empty($result['title'])) {
            if (preg_match_all('/\|\s*([^|]{5,}?)\s*\|/mu', $content, $matches)) {
                foreach ($matches[1] as $candidate) {
                    $candidate = trim($candidate);
                    if (empty($candidate)) continue;
                    // Ignorar: continuaciones, logos, campos de metadatos (cualquier capitalización)
                    if (preg_match('/^(:::|logo|informaci|c[oó]digo|versi|vigencia|nombre|\s|\*)/iu', $candidate)) continue;
                    // Verificar que parece un nombre de documento (texto suficientemente largo)
                    if (mb_strlen($candidate, 'UTF-8') >= 10) {
                        $result['title'] = $candidate;
                        break;
                    }
                }
            }
        }

        // Prioridad 5 (fallback final): usar el nombre de la página
        if (empty($result['title']) && !empty($pageId)) {
            $pageName = noNS($pageId);
            $result['title'] = str_replace('_', ' ', ucwords(str_replace('_', ' ', $pageName)));
        }

        // Limpiar título de marcadores wiki residuales
        $result['title'] = preg_replace('/^[\s\^|*]+/u', '', $result['title']);
        $result['title'] = preg_replace('/[\s\^|*]+$/u', '', $result['title']);
        $result['title'] = str_replace('**', '', $result['title']);
        $result['title'] = trim($result['title']);

        // =====================================================================
        //  CÓDIGO, VERSIÓN, VIGENCIA - buscar por palabra clave en el contenido
        //  Flags: i=case-insensitive, m=multiline, u=Unicode
        //  Variantes soportadas por cada campo:
        //    CODIGO, Codigo, codigo, CÓDIGO, Código, código (y mezclas)
        //    VERSION, Version, version, VERSIÓN, Versión, versión
        //    VIGENCIA, Vigencia, vigencia
        //    Fecha: dd/mm/yyyy, dd-mm-yyyy, dd.mm.yyyy
        //    NOMBRE:, Nombre:, nombre: (para título)
        // =====================================================================

        // CÓDIGO / Código / codigo (con o sin acento, cualquier capitalización)
        if (preg_match('/C[OÓ]DIGO\s*:\s*(.+?)(?:\s*\||$)/imu', $content, $m)) {
            $result['code'] = trim($m[1]);
            $result['code'] = preg_replace('/\s+/', ' ', $result['code']);
            $result['code'] = str_replace('**', '', $result['code']);
            $result['code'] = trim($result['code']);
        }

        // VERSIÓN / Version / version (con o sin acento, con o sin ':')
        if (preg_match('/VERSI[OÓ]N\s*:?\s*(\d+)/imu', $content, $m)) {
            $result['version'] = (int) $m[1];
        }

        // VIGENCIA / Vigencia / vigencia (fecha con / o - o .)
        if (preg_match('/VIGENCIA\s*:\s*([\d\/\-\.]+)/imu', $content, $m)) {
            $result['validity_date'] = trim($m[1]);
        }

        // ÁREA (del tag si existe)
        if (preg_match('/\{\{tag>[AÁ]REA\s*:\s*(.+?)\s*\}\}/iu', $content, $m)) {
            $result['area'] = trim(str_replace('_', ' ', $m[1]));
        }

        return $result;
    }

    /**
     * Parsea la sección ANEXOS Y REGISTROS para extraer enlaces a medias (formatos).
     *
     * Detecta patrones como:
     *   {{ :compartidos:formato_1.docx|NOMBRE DEL FORMATO}}
     *   {{:compartidos:formato_1.docx|NOMBRE}}
     *   {{ :rrhh:evaluacion.xlsx |EVALUACIÓN DE COMPETENCIAS}}
     *
     * @param string $content Contenido wiki crudo de la página
     * @return array Lista de ['media_id' => string, 'display_name' => string]
     */
    public function parseFormats($content)
    {
        $formats = [];

        // Buscar la sección ANEXOS Y REGISTROS
        // Los encabezados pueden tener diferente nivel (====, =====, ======)
        // y pueden tener número (9., 9.-, 5.) o no
        if (!preg_match('/={2,}\s*\d*\.?\-?\s*ANEXOS\s+Y\s+REGISTROS\s*={2,}/i', $content, $m, PREG_OFFSET_CAPTURE)) {
            return $formats;
        }

        // Obtener contenido desde la sección ANEXOS hasta el final o <hidden>
        $startPos = $m[0][1];
        $rest = substr($content, $startPos);

        // Cortar en el próximo encabezado de mismo nivel o superior, o en <hidden>
        if (preg_match('/\n={2,}[^=]|<hidden>/i', $rest, $endMatch, PREG_OFFSET_CAPTURE, 10)) {
            $rest = substr($rest, 0, $endMatch[0][1]);
        }

        // 1. Extraer medias: {{ :ns:file.ext |Nombre}} o {{:ns:file.ext|Nombre}}
        preg_match_all('/\{\{\s*:?([^\}|]+?)(?:\?[^\}|]*)?\s*(?:\|([^\}]*?))?\s*\}\}/', $rest, $mediaMatches, PREG_SET_ORDER);

        foreach ($mediaMatches as $match) {
            $mediaId = trim($match[1]);
            $displayName = isset($match[2]) ? trim($match[2]) : '';

            // Filtrar imágenes comunes que no son formatos
            $ext = strtolower(pathinfo($mediaId, PATHINFO_EXTENSION));
            if (in_array($ext, ['png', 'jpg', 'jpeg', 'gif', 'svg', 'bmp', 'webp'])) {
                continue;
            }

            // Normalizar media_id
            $mediaId = cleanID($mediaId);

            if (!empty($mediaId)) {
                $formats[] = [
                    'media_id'     => $mediaId,
                    'display_name' => $displayName,
                ];
            }
        }

        // 2. Extraer wiki links: [[ns:page|Nombre]] o [[:ns:page|Nombre]]
        preg_match_all('/\[\[\s*:?([^\]|{]+?)(?:\|([^\]]*?))?\s*\]\]/', $rest, $linkMatches, PREG_SET_ORDER);

        foreach ($linkMatches as $match) {
            $pageId = trim($match[1]);
            $displayName = isset($match[2]) ? trim($match[2]) : '';

            // Limpiar prefijo de comillas si existe (ej: "CAL P01 F1 Listado...")
            $displayName = trim($displayName, '" ');

            $pageId = cleanID($pageId);

            if (!empty($pageId) && !empty($displayName)) {
                $formats[] = [
                    'media_id'     => 'link:' . $pageId,
                    'display_name' => $displayName,
                ];
            }
        }

        return $formats;
    }

    /**
     * Parsea el nombre de un formato para extraer código, nombre, versión y vigencia.
     *
     * Patrones soportados:
     *   "M03 F21 - OPORTUNIDAD DE MEJORAMIENTO"           → code=M03 F21, name=OPORTUNIDAD DE MEJORAMIENTO
     *   "M03 F21, OPORTUNIDAD DE MEJORAMIENTO, ver.1, 01/12/2022" → code, name, version=1, validity=01/12/2022
     *   "OPE P04 F1 CHECK_LIST_DOCUMENTOS"                → code=OPE P04 F1, name=CHECK LIST DOCUMENTOS
     *   "FORMATO 10 - NOTIFICACIÓN AL CLIENTE"            → code=FORMATO 10, name=NOTIFICACIÓN AL CLIENTE
     *   "REPORTE DE INCIDENTES"                            → name=REPORTE DE INCIDENTES (sin código)
     *
     * @param string $displayName Texto visible del formato
     * @return array ['code','name','version','validity_date']
     */
    public function parseFormatDisplayName($displayName)
    {
        $result = [
            'code'          => '',
            'name'          => trim($displayName),
            'version'       => 0,
            'validity_date' => '',
        ];

        $text = trim($displayName);
        if (empty($text)) return $result;

        // 1. Extraer versión y vigencia del final: ", ver.N, DD/MM/YYYY" o ", ver.N"
        if (preg_match('/,\s*ver\.?\s*(\d+)\s*(?:,\s*([\d\/\-\.]+))?\s*$/i', $text, $vm)) {
            $result['version'] = (int) $vm[1];
            if (!empty($vm[2])) {
                $result['validity_date'] = trim($vm[2]);
            }
            $text = trim(substr($text, 0, -strlen($vm[0])));
        }

        // 2. Extraer código del inicio
        // Patrón A: XXX[nn] [Pnn] Fnn  (M03 F21, OPE P04 F1, GRH P01 F1, CAL P01 F1)
        // Patrón B: FORMATO nn          (FORMATO 10, FORMATO 24)
        if (preg_match('/^([A-Z]{1,5}\d{0,3}(?:\s+P\d{1,3})?\s+F\d{1,3}|FORMATO\s+\d+)\s*[\-,]\s*/i', $text, $cm)) {
            $result['code'] = strtoupper(trim($cm[1]));
            $result['name'] = trim(substr($text, strlen($cm[0])));
        }
        // Intentar con espacio como separador (OPE P04 F1 CHECK_LIST...)
        elseif (preg_match('/^([A-Z]{1,5}\d{0,3}(?:\s+P\d{1,3})?\s+F\d{1,3})\s+/i', $text, $cm2)) {
            $result['code'] = strtoupper(trim($cm2[1]));
            $result['name'] = trim(substr($text, strlen($cm2[0])));
        } else {
            $result['name'] = $text;
        }

        // Limpiar guiones bajos del nombre
        $result['name'] = str_replace('_', ' ', $result['name']);

        return $result;
    }

    // =========================================================================
    //  OPERACIONES CRUD - DOCUMENTOS
    // =========================================================================

    /**
     * Inserta o actualiza un documento en la base de datos
     *
     * @param string $pageId  Ej: 'compartidos:acciones_correctivas'
     * @param array  $data    ['title','code','version','validity_date','area','last_editor']
     * @return int|false      ID del documento o false en error
     */
    public function upsertDocument($pageId, $data)
    {
        $db = $this->getDB();
        if (!$db) return false;

        $namespace = getNS($pageId);

        // Verificar si ya existe
        $res = $db->query('SELECT id, version FROM documents WHERE page_id = ?', $pageId);
        $existing = $db->res2row($res);

        if ($existing) {
            // UPDATE - preservar versión si no se proporciona explícitamente
            $version = !empty($data['version']) ? (int) $data['version'] : (int) $existing['version'];

            $db->query(
                'UPDATE documents SET namespace = ?, title = ?, code = ?, version = ?,
                 validity_date = ?, area = ?, last_editor = ?, is_active = 1,
                 updated_at = CURRENT_TIMESTAMP
                 WHERE id = ?',
                $namespace,
                $data['title'] ?? '',
                $data['code'] ?? '',
                $version,
                $data['validity_date'] ?? '',
                $data['area'] ?? '',
                $data['last_editor'] ?? '',
                $existing['id']
            );
            return (int) $existing['id'];
        } else {
            // INSERT
            $db->query(
                'INSERT INTO documents (page_id, namespace, title, code, version, validity_date, area, last_editor, is_active)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)',
                $pageId,
                $namespace,
                $data['title'] ?? '',
                $data['code'] ?? '',
                !empty($data['version']) ? (int) $data['version'] : 1,
                $data['validity_date'] ?? '',
                $data['area'] ?? '',
                $data['last_editor'] ?? ''
            );

            $res = $db->query('SELECT last_insert_rowid()');
            $id = $db->res2single($res);
            return $id ? (int) $id : false;
        }
    }

    /**
     * Obtiene un documento por page_id
     *
     * @param string $pageId
     * @return array|null
     */
    public function getDocumentByPage($pageId)
    {
        $db = $this->getDB();
        if (!$db) return null;

        $res = $db->query('SELECT * FROM documents WHERE page_id = ?', $pageId);
        $row = $db->res2row($res);
        return $row ?: null;
    }

    /**
     * Obtiene un documento por su ID
     *
     * @param int $docId
     * @return array|null
     */
    public function getDocument($docId)
    {
        $db = $this->getDB();
        if (!$db) return null;

        $res = $db->query('SELECT * FROM documents WHERE id = ?', (int) $docId);
        $row = $db->res2row($res);
        return $row ?: null;
    }

    /**
     * Obtiene documentos con filtros opcionales
     *
     * @param array $filters ['namespace' => string, 'search' => string, 'active_only' => bool]
     * @return array
     */
    public function getDocuments($filters = [])
    {
        $db = $this->getDB();
        if (!$db) return [];

        $sql = 'SELECT d.*,
                (SELECT COUNT(*) FROM document_formats df WHERE df.document_id = d.id) as format_count
                FROM documents d WHERE 1=1';
        $params = [];

        if (!empty($filters['namespace'])) {
            $sql .= ' AND d.namespace = ?';
            $params[] = $filters['namespace'];
        }

        if (!empty($filters['search'])) {
            $sql .= ' AND (d.title LIKE ? OR d.code LIKE ? OR d.page_id LIKE ?)';
            $search = '%' . $filters['search'] . '%';
            $params[] = $search;
            $params[] = $search;
            $params[] = $search;
        }

        if (!empty($filters['active_only'])) {
            $sql .= ' AND d.is_active = 1';
        }

        $sql .= ' ORDER BY d.namespace, d.title';

        $res = $db->query($sql, $params);
        return $db->res2arr($res);
    }

    /**
     * Obtiene todos los namespaces distintos que tienen documentos
     *
     * @return array
     */
    public function getNamespaces()
    {
        $db = $this->getDB();
        if (!$db) return [];

        $res = $db->query('SELECT DISTINCT namespace FROM documents WHERE is_active = 1 ORDER BY namespace');
        $rows = $db->res2arr($res);

        $namespaces = [];
        foreach ($rows as $row) {
            $namespaces[] = $row['namespace'];
        }
        return $namespaces;
    }

    /**
     * Actualiza un campo específico de un documento (para edición inline)
     *
     * @param int    $docId
     * @param string $field  Solo: 'code', 'version', 'validity_date', 'title', 'area'
     * @param string $value
     * @return bool
     */
    public function updateDocumentField($docId, $field, $value)
    {
        // Whitelist de campos editables
        $allowed = ['code', 'version', 'validity_date', 'title', 'area'];
        if (!in_array($field, $allowed)) {
            return false;
        }

        $db = $this->getDB();
        if (!$db) return false;

        if ($field === 'version') {
            $value = (int) $value;
        }

        $db->query(
            "UPDATE documents SET $field = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
            $value,
            (int) $docId
        );

        return true;
    }

    /**
     * Elimina (desactiva) un documento
     *
     * @param int $docId
     * @return bool
     */
    public function deactivateDocument($docId)
    {
        $db = $this->getDB();
        if (!$db) return false;

        $db->query('UPDATE documents SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (int) $docId);
        return true;
    }

    // =========================================================================
    //  DETECCIÓN Y CORRECCIÓN DE CÓDIGOS DUPLICADOS
    // =========================================================================

    /**
     * Obtiene los códigos duplicados (que aparecen más de una vez).
     *
     * @param string $type 'documents' o 'formats'
     * @return array Mapa [código => cantidad] de los duplicados
     */
    public function getDuplicateCodes($type = 'documents')
    {
        $db = $this->getDB();
        if (!$db) return [];

        $table = ($type === 'formats') ? 'formats' : 'documents';

        // Para documentos filtrar por is_active; formatos no tienen ese campo
        if ($type === 'documents') {
            $sql = "SELECT code, COUNT(*) as cnt FROM documents
                    WHERE is_active = 1 AND code != '' AND code IS NOT NULL
                    GROUP BY code HAVING cnt > 1";
        } else {
            $sql = "SELECT code, COUNT(*) as cnt FROM formats
                    WHERE code != '' AND code IS NOT NULL
                    GROUP BY code HAVING cnt > 1";
        }

        $res = $db->query($sql);
        $rows = $db->res2arr($res);

        $duplicates = [];
        foreach ($rows as $row) {
            $duplicates[$row['code']] = (int) $row['cnt'];
        }
        return $duplicates;
    }

    /**
     * Calcula el siguiente código disponible dado un código actual.
     *
     * Parsea el código separando prefijo de consecutivo numérico final:
     *   'COM P01' → prefix='COM P', num=1, padding=2
     *   'M03 F21' → prefix='M03 F', num=21, padding=2
     *   'FORMATO 10' → prefix='FORMATO ', num=10, padding=0
     *
     * Busca el máximo consecutivo existente con el mismo prefijo y retorna max+1.
     *
     * @param string $currentCode Código actual (ej: 'COM P01')
     * @param string $type 'documents' o 'formats'
     * @return string|false Nuevo código o false si no se puede parsear
     */
    public function getNextAvailableCode($currentCode, $type = 'documents')
    {
        $currentCode = trim($currentCode);
        if (empty($currentCode)) return false;

        // Separar prefijo de número final
        // Ej: 'COM P01' → prefix='COM P', numStr='01'
        if (!preg_match('/^(.+?)(\d+)$/', $currentCode, $m)) {
            return false; // No termina en número
        }

        $prefix = $m[1]; // 'COM P'
        $numStr = $m[2]; // '01'
        $padding = strlen($numStr); // 2

        $db = $this->getDB();
        if (!$db) return false;

        // Buscar todos los códigos con el mismo prefijo
        $table = ($type === 'formats') ? 'formats' : 'documents';
        $likePrefix = str_replace(['%', '_'], ['\\%', '\\_'], $prefix) . '%';

        if ($type === 'documents') {
            $sql = "SELECT code FROM documents WHERE is_active = 1 AND code LIKE ? ESCAPE '\\'";
        } else {
            $sql = "SELECT code FROM formats WHERE code LIKE ? ESCAPE '\\'";
        }

        $res = $db->query($sql, $likePrefix);
        $rows = $db->res2arr($res);

        // Encontrar el máximo consecutivo
        $maxNum = 0;
        $prefixLen = strlen($prefix);
        foreach ($rows as $row) {
            $code = $row['code'];
            if (strpos($code, $prefix) === 0) {
                $numPart = substr($code, $prefixLen);
                if (is_numeric($numPart)) {
                    $n = intval($numPart);
                    if ($n > $maxNum) $maxNum = $n;
                }
            }
        }

        // Siguiente disponible
        $nextNum = $maxNum + 1;

        // Aplicar padding (si el original era '01', mantener 2 dígitos)
        if ($padding > 0) {
            $newNumStr = str_pad($nextNum, $padding, '0', STR_PAD_LEFT);
        } else {
            $newNumStr = (string) $nextNum;
        }

        return $prefix . $newNumStr;
    }

    /**
     * Corrige un código duplicado asignándole el siguiente consecutivo disponible.
     *
     * Actualiza:
     *   1. El campo 'code' en la tabla de la BD
     *   2. (Solo documentos) La línea CÓDIGO: en la página wiki del procedimiento
     *
     * NO modifica versión ni vigencia.
     *
     * @param int    $id   ID del registro
     * @param string $type 'documents' o 'formats'
     * @return array ['success'=>bool, 'old_code'=>string, 'new_code'=>string, 'error'=>string]
     */
    public function fixDuplicateCode($id, $type = 'documents')
    {
        $db = $this->getDB();
        if (!$db) return ['success' => false, 'error' => 'Sin conexión a BD'];

        $id = (int) $id;

        // 1. Obtener registro actual
        if ($type === 'documents') {
            $res = $db->query('SELECT id, code, page_id FROM documents WHERE id = ?', $id);
        } else {
            $res = $db->query('SELECT id, code, media_id FROM formats WHERE id = ?', $id);
        }
        $record = $db->res2row($res);

        if (!$record) {
            return ['success' => false, 'error' => 'Registro no encontrado'];
        }

        $oldCode = trim($record['code']);
        if (empty($oldCode)) {
            return ['success' => false, 'error' => 'El registro no tiene código'];
        }

        // 2. Calcular nuevo código
        $newCode = $this->getNextAvailableCode($oldCode, $type);
        if (!$newCode) {
            return ['success' => false, 'error' => 'No se pudo calcular nuevo código'];
        }

        // 3. Actualizar en BD
        if ($type === 'documents') {
            $db->query(
                'UPDATE documents SET code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
                $newCode, $id
            );
        } else {
            $db->query(
                'UPDATE formats SET code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
                $newCode, $id
            );
        }

        // 4. Actualizar en la(s) página(s) wiki
        if ($type === 'documents' && !empty($record['page_id'])) {
            // Documentos: actualizar línea CÓDIGO: en la página
            $this->_updateCodeInWikiPage($record['page_id'], $oldCode, $newCode);
        } elseif ($type === 'formats') {
            // Formatos: sincronizar el link en las páginas donde está referenciado
            $this->syncFormatToWikiPages($id);
        }

        return [
            'success'  => true,
            'old_code' => $oldCode,
            'new_code' => $newCode,
            'id'       => $id,
        ];
    }

    /**
     * Actualiza la línea CÓDIGO: en una página wiki.
     *
     * Busca el patrón "CÓDIGO : OLD_CODE" (o "Código:", "codigo:", etc.)
     * y lo reemplaza con el nuevo código, preservando el formato.
     *
     * @param string $pageId  ID de la página wiki
     * @param string $oldCode Código anterior
     * @param string $newCode Código nuevo
     * @return bool
     */
    protected function _updateCodeInWikiPage($pageId, $oldCode, $newCode)
    {
        if (!function_exists('rawWiki')) return false;

        $content = rawWiki($pageId);
        if (empty($content)) return false;

        // Escapar caracteres especiales del código para regex
        $oldCodeEsc = preg_quote($oldCode, '/');

        // Buscar: CÓDIGO : COM P01 (case-insensitive, con o sin acento)
        $pattern = '/(C[OÓ]DIGO\s*:\s*)' . $oldCodeEsc . '/iu';
        $newContent = preg_replace($pattern, '${1}' . $newCode, $content, 1);

        if ($newContent !== null && $newContent !== $content) {
            saveWikiText($pageId, $newContent, "Corrección automática de código duplicado: $oldCode → $newCode", false);
            return true;
        }

        return false;
    }

    /**
     * Actualiza un campo (code, version, validity_date) en la página wiki de un documento.
     * Busca el patrón del campo y reemplaza el valor.
     *
     * @param string $pageId      ID de la página wiki
     * @param string $field       'code', 'version' o 'validity_date'
     * @param string $oldValue    Valor anterior
     * @param string $newValue    Valor nuevo
     * @return bool
     */
    public function updateFieldInWikiPage($pageId, $field, $oldValue, $newValue)
    {
        if (!function_exists('rawWiki')) return false;

        $content = rawWiki($pageId);
        if (empty($content)) return false;

        $pattern = null;
        $replacement = null;

        switch ($field) {
            case 'code':
                // CÓDIGO : VALOR (case-insensitive, con o sin acento)
                $oldEsc = preg_quote($oldValue, '/');
                $pattern = '/(C[OÓ]DIGO\s*:\s*)' . $oldEsc . '/iu';
                $replacement = '${1}' . $newValue;
                break;

            case 'version':
                // VERSIÓN : N (case-insensitive, con o sin acento)
                $oldEsc = preg_quote($oldValue, '/');
                $pattern = '/(VERSI[OÓ]N\s*:\s*)' . $oldEsc . '/iu';
                $replacement = '${1}' . $newValue;
                break;

            case 'validity_date':
                // VIGENCIA : DD/MM/YYYY (case-insensitive)
                $oldEsc = preg_quote($oldValue, '/');
                $pattern = '/(VIGENCIA\s*:\s*)' . $oldEsc . '/iu';
                $replacement = '${1}' . $newValue;
                break;

            default:
                return false;
        }

        if (!$pattern) return false;

        $newContent = preg_replace($pattern, $replacement, $content, 1);

        if ($newContent !== null && $newContent !== $content) {
            $fieldLabel = $field === 'code' ? 'código' : ($field === 'version' ? 'versión' : 'vigencia');
            saveWikiText($pageId, $newContent, "Actualización masiva de {$fieldLabel}: {$oldValue} → {$newValue}", false);
            return true;
        }

        return false;
    }

    /**
     * Sincroniza los datos de un formato (code, version, validity) en las páginas wiki
     * donde está referenciado.
     *
     * Busca el media link (ej: {{ :compartidos:archivo.docx|DISPLAY_NAME}}) en cada
     * página relacionada y reconstruye el display_name con los datos actuales del formato.
     *
     * Formato del display_name: "CODE, NOMBRE, ver.VERSION, DD/MM/YYYY"
     *
     * @param int $formatId ID del formato
     * @return array ['updated_pages' => int, 'errors' => string[]]
     */
    public function syncFormatToWikiPages($formatId)
    {
        $result = ['updated_pages' => 0, 'errors' => [], 'debug' => []];

        $db = $this->getDB();
        if (!$db) {
            $result['errors'][] = 'No se pudo conectar a la base de datos';
            return $result;
        }

        // 1. Obtener datos del formato
        $res = $db->query('SELECT * FROM formats WHERE id = ?', (int) $formatId);
        $format = $db->res2row($res);
        if (!$format) {
            $result['errors'][] = "Formato no encontrado: $formatId";
            return $result;
        }

        $mediaId = $format['media_id'];
        $code = trim($format['code']);
        $version = (int) $format['version'];
        $validityDate = trim($format['validity_date']);

        // Parsear display_name actual para obtener el nombre limpio
        $parsed = $this->parseFormatDisplayName($format['display_name']);
        $nombre = $parsed['name'];

        // Reconstruir display_name: CÓDIGO, NOMBRE DEL FORMATO, ver. #, DD/MM/AAAA
        $newDisplayName = '';
        if (!empty($code)) {
            $newDisplayName .= $code . ', ';
        }
        $newDisplayName .= $nombre;
        if ($version > 0) {
            $newDisplayName .= ', ver. ' . $version;
        }
        if (!empty($validityDate)) {
            $newDisplayName .= ', ' . $validityDate;
        }

        $result['debug'][] = 'media_id: ' . $mediaId;
        $result['debug'][] = 'nuevo display_name: ' . $newDisplayName;

        // Actualizar display_name en la tabla formats
        $db->query(
            'UPDATE formats SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
            $newDisplayName, (int) $formatId
        );

        // 2. Obtener páginas donde está referenciado
        $res = $db->query(
            'SELECT d.page_id FROM documents d
             INNER JOIN document_formats df ON d.id = df.document_id
             WHERE df.format_id = ? AND d.is_active = 1',
            (int) $formatId
        );
        $pages = $db->res2arr($res);

        if (empty($pages)) {
            $result['errors'][] = 'No se encontraron paginas vinculadas a este formato';
            return $result;
        }

        $result['debug'][] = 'Paginas vinculadas: ' . count($pages);

        // 3. Actualizar en cada página wiki
        foreach ($pages as $page) {
            $pageId = $page['page_id'];

            if (!function_exists('rawWiki')) {
                $result['errors'][] = 'rawWiki() no disponible para: ' . $pageId;
                continue;
            }

            $content = rawWiki($pageId);
            if (empty($content)) {
                $result['errors'][] = 'Pagina vacia o no encontrada: ' . $pageId;
                continue;
            }

            // Determinar si es media link o wiki link
            $isWikiLink = (strpos($mediaId, 'link:') === 0);
            $realId = $isWikiLink ? substr($mediaId, 5) : $mediaId;

            // Construir nombre de archivo sin namespace para busqueda flexible
            $fileBasename = basename(str_replace(':', '/', $realId));

            $matched = false;
            $newContent = $content;

            if ($isWikiLink) {
                // Wiki link: [[:ns:page|OLD_DISPLAY]]
                // Intento 1: match exacto — reemplaza solo la ÚLTIMA ocurrencia
                $pattern = '/(\[\[\s*:?' . preg_quote($realId, '/') . '\s*\|)[^\]]*(\]\])/i';
                $newContent = $this->_replaceLastOccurrence($pattern, '${1}' . $newDisplayName . '${2}', $content, $count);
                if ($count > 0) $matched = true;

                // Intento 2: match solo por nombre de archivo (sin namespace completo)
                if (!$matched) {
                    $pattern2 = '/(\[\[\s*:?[^\]|]*' . preg_quote($fileBasename, '/') . '\s*\|)[^\]]*(\]\])/i';
                    $newContent = $this->_replaceLastOccurrence($pattern2, '${1}' . $newDisplayName . '${2}', $content, $count);
                    if ($count > 0) $matched = true;
                }
            } else {
                // Media link: {{ :ns:file.ext|OLD_DISPLAY}}
                // Intento 1: match exacto del media_id — reemplaza solo la ÚLTIMA ocurrencia
                $pattern = '/(\{\{\s*:?' . preg_quote($realId, '/') . '(?:\?[^\}|]*)?\s*\|)[^\}]*(\}\})/i';
                $newContent = $this->_replaceLastOccurrence($pattern, '${1}' . $newDisplayName . '${2}', $content, $count);
                if ($count > 0) $matched = true;

                // Intento 2: match solo por nombre de archivo (sin namespace)
                if (!$matched) {
                    $pattern2 = '/(\{\{\s*:?[^\}|]*' . preg_quote($fileBasename, '/') . '(?:\?[^\}|]*)?\s*\|)[^\}]*(\}\})/i';
                    $newContent = $this->_replaceLastOccurrence($pattern2, '${1}' . $newDisplayName . '${2}', $content, $count);
                    if ($count > 0) $matched = true;
                }

                // Intento 3: match sin pipe (formato sin display_name, solo archivo)
                if (!$matched) {
                    $pattern3 = '/(\{\{\s*:?[^\}|]*' . preg_quote($fileBasename, '/') . '(?:\?[^\}|]*)?\s*)(\}\})/i';
                    $newContent = $this->_replaceLastOccurrence($pattern3, '${1}|' . $newDisplayName . '${2}', $content, $count);
                    if ($count > 0) $matched = true;
                }
            }

            if ($matched && $newContent !== null && $newContent !== $content) {
                saveWikiText($pageId, $newContent, 'Docmaster sync: ' . $newDisplayName, false);
                $result['updated_pages']++;
                $result['debug'][] = 'Actualizado: ' . $pageId;
            } else {
                $result['errors'][] = 'No se encontro el link en: ' . $pageId . ' (media_id: ' . $realId . ')';
            }
        }

        return $result;
    }

    /**
     * Reemplaza solo la ÚLTIMA ocurrencia de un patrón regex en un string.
     *
     * @param string $pattern  Patrón regex
     * @param string $replacement  Cadena de reemplazo (soporta backreferences ${1}, ${2}, etc.)
     * @param string $subject  Texto donde buscar
     * @param int &$count  Se setea a 1 si hubo reemplazo, 0 si no
     * @return string  Texto con la última ocurrencia reemplazada
     */
    private function _replaceLastOccurrence($pattern, $replacement, $subject, &$count) {
        $count = 0;
        if (preg_match_all($pattern, $subject, $matches, PREG_OFFSET_CAPTURE) === false || empty($matches[0])) {
            return $subject;
        }
        // Tomar la última coincidencia
        $last = end($matches[0]);
        $lastMatch = $last[0];
        $lastOffset = $last[1];

        // Aplicar preg_replace solo sobre la última coincidencia para resolver backreferences
        $replaced = preg_replace($pattern, $replacement, $lastMatch, 1);

        // Reconstruir el string: antes + reemplazo + después
        $result = substr($subject, 0, $lastOffset) . $replaced . substr($subject, $lastOffset + strlen($lastMatch));
        $count = 1;
        return $result;
    }

    /**
     * Reparador General: sincroniza TODOS los formatos (o los seleccionados) con sus páginas wiki.
     *
     * Para cada formato, reconstruye el display_name (código, nombre, versión, vigencia)
     * y actualiza los links en las páginas wiki donde está referenciado.
     *
     * @param array|null $filter  Si es null, repara TODOS los formatos.
     *                            Si es array de IDs, solo repara esos formatos.
     * @return array  ['repaired' => [...], 'skipped' => [...], 'errors' => [...],
     *                 'total_formats' => int, 'total_pages_updated' => int]
     */
    public function repairAllFormats($filter = null)
    {
        $repaired = [];
        $skipped  = [];
        $errors   = [];
        $totalPagesUpdated = 0;

        $db = $this->getDB();
        if (!$db) {
            return [
                'repaired'            => [],
                'skipped'             => [],
                'errors'              => [['id' => 0, 'display_name' => '', 'media_id' => '', 'errors' => ['No se pudo conectar a la base de datos']]],
                'total_formats'       => 0,
                'total_pages_updated' => 0,
            ];
        }

        // Obtener formatos: filtrados o todos
        if ($filter !== null && is_array($filter) && !empty($filter)) {
            $placeholders = implode(',', array_fill(0, count($filter), '?'));
            $params = array_map('intval', $filter);
            $res = $db->query("SELECT * FROM formats WHERE id IN ($placeholders)", $params);
        } else {
            $res = $db->query('SELECT * FROM formats ORDER BY id');
        }
        $formats = $db->res2arr($res);

        foreach ($formats as $format) {
            $fmtId = (int) $format['id'];

            // Verificar si el formato tiene páginas vinculadas
            $res2 = $db->query(
                'SELECT COUNT(*) as cnt FROM document_formats df
                 INNER JOIN documents d ON d.id = df.document_id
                 WHERE df.format_id = ? AND d.is_active = 1',
                $fmtId
            );
            $row = $db->res2row($res2);
            $linkedPages = (int) ($row['cnt'] ?? 0);

            if ($linkedPages === 0) {
                $skipped[] = [
                    'id'           => $fmtId,
                    'display_name' => $format['display_name'] ?? '',
                    'media_id'     => $format['media_id'] ?? '',
                    'reason'       => 'Sin páginas vinculadas',
                ];
                continue;
            }

            // Ejecutar la sincronización existente
            $syncResult = $this->syncFormatToWikiPages($fmtId);

            if (!empty($syncResult['errors']) && $syncResult['updated_pages'] === 0) {
                $errors[] = [
                    'id'           => $fmtId,
                    'display_name' => $format['display_name'] ?? '',
                    'media_id'     => $format['media_id'] ?? '',
                    'errors'       => $syncResult['errors'],
                ];
            } else {
                $repaired[] = [
                    'id'            => $fmtId,
                    'display_name'  => $format['display_name'] ?? '',
                    'media_id'      => $format['media_id'] ?? '',
                    'updated_pages' => $syncResult['updated_pages'],
                    'errors'        => $syncResult['errors'],
                ];
                $totalPagesUpdated += $syncResult['updated_pages'];
            }
        }

        return [
            'repaired'            => $repaired,
            'skipped'             => $skipped,
            'errors'              => $errors,
            'total_formats'       => count($formats),
            'total_pages_updated' => $totalPagesUpdated,
        ];
    }

    /**
     * Corrige TODOS los códigos duplicados de documentos y formatos.
     *
     * Para cada código duplicado, deja el primero (por ID más bajo) sin cambiar
     * y corrige los siguientes asignándoles consecutivos disponibles.
     *
     * @return array ['corrections' => [...], 'errors' => [...]]
     *   Cada correction: {id, type, old_code, new_code, title, page_id}
     */
    /**
     * Corregir códigos duplicados.
     *
     * @param array|null $filter  Si es null, corrige TODOS los duplicados.
     *                            Si es array con 'documents' y/o 'formats' (arrays de IDs),
     *                            solo corrige los items en esas listas que tengan código duplicado.
     * @return array  ['corrections' => [...], 'errors' => [...]]
     */
    public function fixAllDuplicates($filter = null)
    {
        $corrections = [];
        $errors = [];

        // Procesar documentos y formatos
        foreach (['documents', 'formats'] as $type) {
            $filterKey = $type; // 'documents' o 'formats'

            // Si hay filtro y este tipo tiene lista vacía, saltarlo
            if ($filter !== null && empty($filter[$filterKey])) {
                continue;
            }

            $duplicates = $this->getDuplicateCodes($type);

            foreach ($duplicates as $code => $count) {
                $db = $this->getDB();
                if (!$db) continue;

                if ($type === 'documents') {
                    $res = $db->query(
                        'SELECT id, code, page_id, title FROM documents WHERE is_active = 1 AND code = ? ORDER BY id ASC',
                        $code
                    );
                } else {
                    $res = $db->query(
                        'SELECT id, code, media_id, display_name FROM formats WHERE code = ? ORDER BY id ASC',
                        $code
                    );
                }
                $rows = $db->res2arr($res);

                // Saltar el primero (es el "original"), corregir los demás
                for ($i = 1; $i < count($rows); $i++) {
                    $row = $rows[$i];
                    $rowId = (int) $row['id'];

                    // Si hay filtro, solo corregir IDs que estén en la lista
                    if ($filter !== null) {
                        $allowedIds = array_map('intval', $filter[$filterKey]);
                        if (!in_array($rowId, $allowedIds)) {
                            continue; // Este ID no fue seleccionado, saltarlo
                        }
                    }

                    $result = $this->fixDuplicateCode($rowId, $type);

                    if ($result['success']) {
                        $corrections[] = [
                            'id'       => $rowId,
                            'type'     => $type === 'documents' ? 'document' : 'format',
                            'old_code' => $result['old_code'],
                            'new_code' => $result['new_code'],
                            'title'    => $type === 'documents'
                                ? ($row['title'] ?? $row['page_id'] ?? '')
                                : ($row['display_name'] ?? $row['media_id'] ?? ''),
                            'page_id'  => $type === 'documents'
                                ? ($row['page_id'] ?? '')
                                : ($row['media_id'] ?? ''),
                        ];
                    } else {
                        $errors[] = [
                            'id'    => $rowId,
                            'type'  => $type === 'documents' ? 'document' : 'format',
                            'code'  => $code,
                            'error' => $result['error'] ?? 'Error desconocido',
                        ];
                    }
                }
            }
        }

        return [
            'corrections' => $corrections,
            'errors'      => $errors,
        ];
    }

    // =========================================================================
    //  OPERACIONES CRUD - FORMATOS
    // =========================================================================

    /**
     * Inserta o actualiza un formato en el registro compartido.
     * Preserva valores existentes de code/version/validity si ya fueron editados manualmente.
     *
     * @param string $mediaId      Ej: 'compartidos:formato_1.docx' o 'link:ns:page'
     * @param string $displayName  Nombre visible del formato
     * @param string $code         Código parseado (ej: 'M03 F21')
     * @param int    $version      Versión parseada
     * @param string $validityDate Vigencia parseada (ej: '01/12/2022')
     * @return int|false           ID del formato o false en error
     */
    public function upsertFormat($mediaId, $displayName = '', $code = '', $version = 0, $validityDate = '')
    {
        $db = $this->getDB();
        if (!$db) return false;

        $res = $db->query('SELECT id, code, version, validity_date FROM formats WHERE media_id = ?', $mediaId);
        $existing = $db->res2row($res);

        if ($existing) {
            // UPDATE - construir dinámicamente para preservar ediciones manuales
            $updates = [];
            $params = [];

            if (!empty($displayName)) {
                $updates[] = 'display_name = ?';
                $params[] = $displayName;
            }

            // Solo actualizar code/version/validity si el valor actual está vacío
            if (!empty($code) && empty($existing['code'])) {
                $updates[] = 'code = ?';
                $params[] = $code;
            }
            if ($version > 0 && (empty($existing['version']) || intval($existing['version']) == 0)) {
                $updates[] = 'version = ?';
                $params[] = $version;
            }
            if (!empty($validityDate) && empty($existing['validity_date'])) {
                $updates[] = 'validity_date = ?';
                $params[] = $validityDate;
            }

            if (!empty($updates)) {
                $sql = 'UPDATE formats SET ' . implode(', ', $updates) . ', updated_at = CURRENT_TIMESTAMP WHERE id = ?';
                $params[] = $existing['id'];
                $db->query($sql, $params);
            }

            return (int) $existing['id'];
        } else {
            // INSERT con todos los campos parseados
            $db->query(
                'INSERT INTO formats (media_id, display_name, code, version, validity_date) VALUES (?, ?, ?, ?, ?)',
                $mediaId,
                $displayName,
                $code,
                $version > 0 ? $version : 0,
                $validityDate
            );
            $res = $db->query('SELECT last_insert_rowid()');
            $id = $db->res2single($res);
            return $id ? (int) $id : false;
        }
    }

    /**
     * Obtiene formatos con filtros
     *
     * @param array $filters ['search' => string]
     * @return array
     */
    public function getFormats($filters = [])
    {
        $db = $this->getDB();
        if (!$db) return [];

        $sql = 'SELECT f.*,
                (SELECT COUNT(*) FROM document_formats df WHERE df.format_id = f.id) as document_count,
                (SELECT GROUP_CONCAT(d.title, "||") FROM documents d
                 INNER JOIN document_formats df ON d.id = df.document_id
                 WHERE df.format_id = f.id AND d.is_active = 1) as document_titles,
                (SELECT GROUP_CONCAT(d.namespace, "||") FROM documents d
                 INNER JOIN document_formats df ON d.id = df.document_id
                 WHERE df.format_id = f.id AND d.is_active = 1) as document_namespaces,
                (SELECT GROUP_CONCAT(d.page_id, "||") FROM documents d
                 INNER JOIN document_formats df ON d.id = df.document_id
                 WHERE df.format_id = f.id AND d.is_active = 1) as document_page_ids
                FROM formats f WHERE 1=1';
        $params = [];

        if (!empty($filters['search'])) {
            $sql .= ' AND (f.display_name LIKE ? OR f.media_id LIKE ? OR f.code LIKE ?)';
            $search = '%' . $filters['search'] . '%';
            $params[] = $search;
            $params[] = $search;
            $params[] = $search;
        }

        $sql .= ' ORDER BY f.display_name';

        $res = $db->query($sql, $params);
        return $db->res2arr($res);
    }

    /**
     * Actualiza un campo específico de un formato
     *
     * @param int    $fmtId
     * @param string $field  Solo: 'display_name', 'code', 'version', 'validity_date'
     * @param string $value
     * @return bool
     */
    public function updateFormatField($fmtId, $field, $value)
    {
        $allowed = ['display_name', 'code', 'version', 'validity_date'];
        if (!in_array($field, $allowed)) {
            return false;
        }

        $db = $this->getDB();
        if (!$db) return false;

        if ($field === 'version') {
            $value = (int) $value;
        }

        $db->query(
            "UPDATE formats SET $field = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
            $value,
            (int) $fmtId
        );

        return true;
    }

    // =========================================================================
    //  RELACIONES DOCUMENTO ↔ FORMATO
    // =========================================================================

    /**
     * Vincula un documento con un formato
     *
     * @param int    $docId
     * @param int    $fmtId
     * @param string $sectionName
     * @return bool
     */
    public function linkDocumentFormat($docId, $fmtId, $sectionName = 'ANEXOS Y REGISTROS')
    {
        $db = $this->getDB();
        if (!$db) return false;

        $db->query(
            'INSERT OR IGNORE INTO document_formats (document_id, format_id, section_name) VALUES (?, ?, ?)',
            (int) $docId,
            (int) $fmtId,
            $sectionName
        );

        return true;
    }

    /**
     * Elimina todos los vínculos de formatos de un documento
     * (se llama antes de re-vincular al guardar)
     *
     * @param int $docId
     * @return bool
     */
    public function unlinkDocumentFormats($docId)
    {
        $db = $this->getDB();
        if (!$db) return false;

        $db->query('DELETE FROM document_formats WHERE document_id = ?', (int) $docId);
        return true;
    }

    /**
     * Obtiene los formatos vinculados a un documento
     *
     * @param int $docId
     * @return array
     */
    public function getDocumentFormats($docId)
    {
        $db = $this->getDB();
        if (!$db) return [];

        $res = $db->query(
            'SELECT f.*, df.section_name
             FROM formats f
             INNER JOIN document_formats df ON f.id = df.format_id
             WHERE df.document_id = ?
             ORDER BY f.display_name',
            (int) $docId
        );

        return $db->res2arr($res);
    }

    // =========================================================================
    //  VERSIONADO
    // =========================================================================

    /**
     * Incrementa la versión de un documento y registra en historial
     *
     * @param int    $docId
     * @param string $user       Usuario que realizó el cambio
     * @param string $newDate    Nueva fecha de vigencia (opcional, por defecto hoy)
     * @return int               Nueva versión
     */
    public function incrementVersion($docId, $user, $newDate = '')
    {
        $db = $this->getDB();
        if (!$db) return 0;

        // Obtener versión actual
        $res = $db->query('SELECT version FROM documents WHERE id = ?', (int) $docId);
        $row = $db->res2row($res);
        if (!$row) return 0;

        $oldVersion = (int) $row['version'];
        $newVersion = $oldVersion + 1;

        // Fecha de vigencia: usar la proporcionada o la fecha de hoy
        if (empty($newDate)) {
            $newDate = date('d/m/Y');
        }

        // Actualizar documento
        $db->query(
            'UPDATE documents SET version = ?, validity_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
            $newVersion,
            $newDate,
            (int) $docId
        );

        // Registrar en historial
        $db->query(
            'INSERT INTO version_history (document_id, old_version, new_version, changed_by) VALUES (?, ?, ?, ?)',
            (int) $docId,
            $oldVersion,
            $newVersion,
            $user
        );

        return $newVersion;
    }

    /**
     * Obtiene el historial de versiones de un documento
     *
     * @param int $docId
     * @return array
     */
    public function getVersionHistory($docId)
    {
        $db = $this->getDB();
        if (!$db) return [];

        $res = $db->query(
            'SELECT * FROM version_history WHERE document_id = ? ORDER BY changed_at DESC',
            (int) $docId
        );

        return $db->res2arr($res);
    }

    // =========================================================================
    //  PROCESAMIENTO DE PÁGINAS
    // =========================================================================

    /**
     * Procesa una página: parsea encabezado y formatos, actualiza la DB.
     *
     * @param string $pageId   Ej: 'compartidos:acciones_correctivas'
     * @param string $content  Contenido wiki de la página
     * @param string $user     Usuario que guardó (opcional)
     * @return array|false     ['doc_id' => int, 'formats_linked' => int] o false
     */
    public function processPage($pageId, $content, $user = '')
    {
        // Verificar namespace calificado
        if (!$this->isQualifiedNamespace($pageId)) {
            return false;
        }

        // Parsear encabezado (con pageId para fallback de título)
        $header = $this->parseHeader($content, $pageId);

        // Si no hay título ni código, probablemente no es un documento formal
        // Con el fallback de parseHeader, esto solo ocurre si pageId está vacío
        if (empty($header['title']) && empty($header['code'])) {
            return false;
        }

        // Upsert documento
        $data = [
            'title'         => $header['title'],
            'code'          => $header['code'],
            'version'       => $header['version'],
            'validity_date' => $header['validity_date'],
            'area'          => $header['area'],
            'last_editor'   => $user,
        ];

        $docId = $this->upsertDocument($pageId, $data);
        if (!$docId) return false;

        // Parsear formatos de ANEXOS Y REGISTROS
        $formats = $this->parseFormats($content);

        // Re-vincular formatos: eliminar existentes y crear nuevos
        $this->unlinkDocumentFormats($docId);

        $formatsLinked = 0;
        foreach ($formats as $fmt) {
            // Parsear nombre para extraer código, versión, vigencia
            $parsed = $this->parseFormatDisplayName($fmt['display_name']);

            $fmtId = $this->upsertFormat(
                $fmt['media_id'],
                $parsed['name'] ?: $fmt['display_name'],
                $parsed['code'],
                $parsed['version'],
                $parsed['validity_date']
            );
            if ($fmtId) {
                $this->linkDocumentFormat($docId, $fmtId);
                $formatsLinked++;
            }
        }

        return [
            'doc_id'         => $docId,
            'formats_linked' => $formatsLinked,
        ];
    }

    /**
     * Escanea TODAS las páginas de los namespaces calificados
     * y actualiza la base de datos.
     *
     * @return array ['processed' => int, 'skipped' => int, 'errors' => int,
     *                'dirs_found' => int, 'dirs_checked' => int, 'files_found' => int,
     *                'datadir' => string, 'skipped_pages' => array]
     */
    public function scanAllPages()
    {
        global $conf;

        $stats = [
            'processed'     => 0,
            'skipped'       => 0,
            'errors'        => 0,
            'dirs_found'    => 0,
            'dirs_checked'  => 0,
            'files_found'   => 0,
            'removed'       => 0,
            'datadir'       => '',
            'skipped_pages' => [],
        ];

        $dataDir = $conf['datadir'];
        $stats['datadir'] = $dataDir;

        if (empty($dataDir) || !is_dir($dataDir)) {
            $stats['errors']++;
            $stats['skipped_pages'][] = 'ERROR: datadir no existe: ' . $dataDir;
            return $stats;
        }

        // Paso 1: Limpiar documentos de rutas excluidas
        $stats['removed'] = $this->_removeExcludedDocuments();

        // Paso 2: Escanear namespaces calificados
        $raw = $this->getConf('qualified_namespaces');
        $namespaces = array_map('trim', explode(',', $raw));
        $namespaces = array_filter($namespaces);

        foreach ($namespaces as $ns) {
            $nsDir = $dataDir . '/' . str_replace(':', '/', $ns);
            $stats['dirs_checked']++;

            if (!is_dir($nsDir)) continue;

            $stats['dirs_found']++;
            $this->_scanDirectory($nsDir, $ns, $stats);
        }

        return $stats;
    }

    /**
     * Elimina de la base de datos los documentos cuyo page_id
     * pertenece a una ruta excluida (excluded_paths).
     *
     * @return int Número de documentos eliminados
     */
    protected function _removeExcludedDocuments()
    {
        $db = $this->getDB();
        if (!$db) return 0;

        // Cargar rutas excluidas
        $raw = $this->getConf('excluded_paths');
        $excluded = array_map('trim', explode(',', $raw));
        $excluded = array_filter($excluded);

        if (empty($excluded)) return 0;

        $removed = 0;

        // Obtener todos los documentos activos
        $res = $db->query('SELECT id, page_id FROM documents WHERE is_active = 1');
        $docs = $db->res2arr($res);

        foreach ($docs as $doc) {
            $pageNs = $doc['page_id']; // ej: operativo:descripciones_operativas:impuestos
            foreach ($excluded as $excl) {
                // El page_id empieza con la ruta excluida
                if ($pageNs === $excl || strpos($pageNs, $excl . ':') === 0) {
                    // Desactivar documento y limpiar sus vínculos
                    $this->unlinkDocumentFormats((int) $doc['id']);
                    $db->query('DELETE FROM documents WHERE id = ?', (int) $doc['id']);
                    $removed++;
                    break;
                }
            }
        }

        return $removed;
    }

    /**
     * Escanea un directorio recursivamente buscando páginas wiki.
     *
     * Cada archivo .txt se procesa individualmente con try-catch para
     * evitar que un error en una página detenga el escaneo completo.
     *
     * @param string $dir    Directorio físico
     * @param string $ns     Namespace base
     * @param array  &$stats Contadores
     */
    protected function _scanDirectory($dir, $ns, &$stats)
    {
        $files = @scandir($dir);
        if (!$files) return;

        foreach ($files as $file) {
            if ($file === '.' || $file === '..') continue;

            $path = $dir . '/' . $file;

            if (is_dir($path)) {
                // Recursar en subdirectorios (si no está excluido)
                $subNs = $ns . ':' . $file;
                if ($this->isExcludedPath($subNs)) {
                    $stats['skipped_pages'][] = $subNs . '/* (excluido)';
                    continue;
                }
                $this->_scanDirectory($path, $subNs, $stats);
            } elseif (pathinfo($file, PATHINFO_EXTENSION) === 'txt') {
                $pageName = pathinfo($file, PATHINFO_FILENAME);
                $pageId = $ns . ':' . $pageName;
                $stats['files_found']++;

                try {
                    // Leer contenido
                    $content = @file_get_contents($path);
                    if ($content === false) {
                        $stats['errors']++;
                        $stats['skipped_pages'][] = $pageId . ' (no se pudo leer)';
                        continue;
                    }

                    // Contenido vacío
                    if (empty(trim($content))) {
                        $stats['skipped']++;
                        $stats['skipped_pages'][] = $pageId . ' (vacío)';
                        continue;
                    }

                    // Procesar documento en la DB de docmaster
                    $result = $this->processPage($pageId, $content);
                    if ($result) {
                        $stats['processed']++;
                    } else {
                        $stats['skipped']++;
                        $stats['skipped_pages'][] = $pageId . ' (sin encabezado válido)';
                    }
                } catch (\Exception $e) {
                    $stats['errors']++;
                    $stats['skipped_pages'][] = $pageId . ' (excepción: ' . $e->getMessage() . ')';
                } catch (\Error $e) {
                    $stats['errors']++;
                    $stats['skipped_pages'][] = $pageId . ' (error: ' . $e->getMessage() . ')';
                }
            }
        }
    }

    // =========================================================================
    //  ACTUALIZACIÓN DE CONTENIDO DE PÁGINA (auto-versionado)
    // =========================================================================

    /**
     * Actualiza la versión y vigencia dentro del contenido wiki de una página.
     * Modifica tanto el encabezado visible como los tags ocultos.
     *
     * @param string $content    Contenido wiki actual
     * @param int    $newVersion Nueva versión
     * @param string $newDate    Nueva fecha de vigencia (dd/mm/yyyy)
     * @return string            Contenido modificado
     */
    public function updatePageContent($content, $newVersion, $newDate)
    {
        // --- Actualizar encabezado visible ---
        // VERSION:N → VERSION:newVersion
        $content = preg_replace(
            '/(VERSI[OÓ]N\s*:\s*)\d+/i',
            '${1}' . $newVersion,
            $content
        );

        // VIGENCIA:dd/mm/yyyy → VIGENCIA:newDate
        $content = preg_replace(
            '/(VIGENCIA\s*:\s*)[\d\/\-\.]+/i',
            '${1}' . $newDate,
            $content
        );

        // --- Actualizar tags ocultos ---
        // {{tag>VERSIÓN:N}} → {{tag>VERSIÓN:newVersion}}
        $content = preg_replace(
            '/(\{\{tag>VERSI[OÓ]N\s*:\s*)\d+(\s*\}\})/i',
            '${1}' . $newVersion . '${2}',
            $content
        );

        // {{tag>VIGENCIA:dd-mm-yyyy}} → {{tag>VIGENCIA:newDate}}
        // Nota: en tags se usa guión, en encabezado se usa barra
        $tagDate = str_replace('/', '-', $newDate);
        $content = preg_replace(
            '/(\{\{tag>VIGENCIA\s*:\s*)[\d\/\-\.]+(\s*\}\})/i',
            '${1}' . $tagDate . '${2}',
            $content
        );

        return $content;
    }

    // =========================================================================
    //  ESTADÍSTICAS
    // =========================================================================

    /**
     * Obtiene estadísticas generales del listado maestro
     *
     * @return array
     */
    public function getStats()
    {
        $db = $this->getDB();
        if (!$db) return [];

        $stats = [];

        $res = $db->query('SELECT COUNT(*) FROM documents WHERE is_active = 1');
        $stats['total_documents'] = (int) $db->res2single($res);

        $res = $db->query('SELECT COUNT(*) FROM formats');
        $stats['total_formats'] = (int) $db->res2single($res);

        $res = $db->query('SELECT COUNT(DISTINCT namespace) FROM documents WHERE is_active = 1');
        $stats['total_namespaces'] = (int) $db->res2single($res);

        $res = $db->query(
            'SELECT COUNT(*) FROM formats f
             WHERE (SELECT COUNT(*) FROM document_formats df WHERE df.format_id = f.id) > 1'
        );
        $stats['shared_formats'] = (int) $db->res2single($res);

        $res = $db->query('SELECT COUNT(*) FROM version_history');
        $stats['total_version_changes'] = (int) $db->res2single($res);

        return $stats;
    }
}
