package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.history.DocumentHistoryRow;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQuery;
import de.gecheckt.pdf.umbenenner.application.port.out.history.HistoryQueryPort;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;

/**
 * SQLite-Implementierung von {@link HistoryQueryPort}.
 * <p>
 * Kapselt alle lesenden Datenbankoperationen für den Historien-Tab.
 * Sämtliche JDBC-Details sind strikt in dieser Klasse eingeschlossen;
 * keine JDBC-Typen erscheinen im Port-Interface oder in Domänen-/Application-Typen.
 * <p>
 * <strong>Suche:</strong> Freitextsuche ist case-insensitiv (via {@code LOWER()}).
 * Sonderzeichen {@code %} und {@code _} in der Benutzereingabe werden vor dem
 * SQL-LIKE-Aufruf mit {@code \} escaped.
 * <p>
 * <strong>Sortierung:</strong> Standard absteigend nach {@code updated_at},
 * Tie-Breaker aufsteigend nach {@code fingerprint} (stabil und reproduzierbar).
 * <p>
 * <strong>Limit:</strong> Wird direkt als SQL-{@code LIMIT} angewendet.
 * Ein Limit von 501 ermöglicht der aufrufenden Schicht zu erkennen, ob mehr
 * als 500 Treffer vorhanden sind.
 */
public class SqliteHistoryQueryAdapter implements HistoryQueryPort {

    private static final Logger logger = LogManager.getLogger(SqliteHistoryQueryAdapter.class);

    private static final String PRAGMA_FOREIGN_KEYS_ON = "PRAGMA foreign_keys = ON";

    private final String jdbcUrl;

    /**
     * Erzeugt den Adapter mit der JDBC-URL der SQLite-Datenbankdatei.
     *
     * @param jdbcUrl die JDBC-URL der SQLite-Datenbank; darf nicht {@code null} oder leer sein
     * @throws NullPointerException     wenn {@code jdbcUrl} null ist
     * @throws IllegalArgumentException wenn {@code jdbcUrl} leer ist
     */
    public SqliteHistoryQueryAdapter(String jdbcUrl) {
        Objects.requireNonNull(jdbcUrl, "jdbcUrl darf nicht null sein");
        if (jdbcUrl.isBlank()) {
            throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
        }
        this.jdbcUrl = jdbcUrl;
    }

    /**
     * {@inheritDoc}
     * <p>
     * Die SQL-Abfrage aggregiert die Versuchsanzahl per {@code COUNT}-Subquery.
     * Freitextsuche und Status-Filter werden als optionale WHERE-Klauseln ergänzt.
     *
     * @param query Abfrageparameter; darf nicht {@code null} sein
     * @return unveränderliche Liste der Trefferzeilen; nie {@code null}; kann leer sein
     * @throws DocumentPersistenceException bei technischen Datenbankfehlern
     */
    @Override
    public List<DocumentHistoryRow> loadOverview(HistoryQuery query) {
        Objects.requireNonNull(query, "query darf nicht null sein");

        StringBuilder sql = new StringBuilder("""
                SELECT
                    dr.fingerprint,
                    dr.overall_status,
                    dr.last_known_source_file_name,
                    dr.last_target_file_name,
                    dr.last_known_source_locator,
                    dr.updated_at,
                    (SELECT COUNT(*) FROM processing_attempt pa WHERE pa.fingerprint = dr.fingerprint) AS attempt_count
                FROM document_record dr
                WHERE 1=1
                """);

        List<Object> params = new ArrayList<>();

        // Freitextsuche: case-insensitiv über Quelldateiname und Zieldateiname
        String searchText = query.searchText();
        if (searchText != null && !searchText.isBlank()) {
            String escaped = escapeSqlLike(searchText.strip().toLowerCase());
            sql.append(" AND (LOWER(dr.last_known_source_file_name) LIKE ? ESCAPE '\\' "
                     + "OR LOWER(dr.last_target_file_name) LIKE ? ESCAPE '\\')");
            String pattern = "%" + escaped + "%";
            params.add(pattern);
            params.add(pattern);
        }

        // Status-Filter
        String statusFilter = query.statusFilter();
        if (statusFilter != null && !statusFilter.isBlank()) {
            sql.append(" AND dr.overall_status = ?");
            params.add(statusFilter.strip());
        }

        sql.append(" ORDER BY dr.updated_at DESC, dr.fingerprint ASC");
        sql.append(" LIMIT ?");
        params.add(query.limit());

        try (Connection connection = getConnection();
             Statement pragmaStmt = connection.createStatement();
             PreparedStatement stmt = connection.prepareStatement(sql.toString())) {

            pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);

            for (int i = 0; i < params.size(); i++) {
                stmt.setObject(i + 1, params.get(i));
            }

            try (ResultSet rs = stmt.executeQuery()) {
                List<DocumentHistoryRow> rows = new ArrayList<>();
                while (rs.next()) {
                    rows.add(mapToDocumentHistoryRow(rs));
                }
                logger.debug("Historien-Übersicht geladen: {} Zeilen (Limit {})", rows.size(), query.limit());
                return List.copyOf(rows);
            }

        } catch (SQLException e) {
            String message = "Historien-Übersicht konnte nicht geladen werden: " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    /**
     * {@inheritDoc}
     *
     * @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
     * @return Optional mit dem Stammsatz, oder leer wenn nicht vorhanden
     * @throws DocumentPersistenceException bei technischen Datenbankfehlern
     */
    @Override
    public Optional<DocumentRecord> findRecordByFingerprint(DocumentFingerprint fingerprint) {
        Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");

        String sql = """
                SELECT
                    last_known_source_locator,
                    last_known_source_file_name,
                    overall_status,
                    content_error_count,
                    transient_error_count,
                    last_failure_instant,
                    last_success_instant,
                    created_at,
                    updated_at,
                    last_target_path,
                    last_target_file_name
                FROM document_record
                WHERE fingerprint = ?
                """;

        try (Connection connection = getConnection();
             PreparedStatement stmt = connection.prepareStatement(sql)) {

            stmt.setString(1, fingerprint.sha256Hex());

            try (ResultSet rs = stmt.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapToDocumentRecord(rs, fingerprint));
                }
                return Optional.empty();
            }

        } catch (SQLException e) {
            String message = "Dokument-Stammsatz konnte nicht geladen werden für Fingerprint '"
                    + fingerprint.sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    /**
     * {@inheritDoc}
     *
     * @param fingerprint Dokumentbezeichner; darf nicht {@code null} sein
     * @return unveränderliche Liste der Versuche aufsteigend nach {@code attempt_number};
     *         nie {@code null}; kann leer sein
     * @throws DocumentPersistenceException bei technischen Datenbankfehlern
     */
    @Override
    public List<ProcessingAttempt> findAttemptsByFingerprint(DocumentFingerprint fingerprint) {
        Objects.requireNonNull(fingerprint, "fingerprint darf nicht null sein");

        String sql = """
                SELECT
                    fingerprint, run_id, attempt_number, started_at, ended_at,
                    status, failure_class, failure_message, retryable,
                    ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
                    ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
                    final_target_file_name,
                    input_tokens, output_tokens,
                    cache_creation_input_tokens, cache_read_input_tokens,
                    price_input_per_token_nano_usd, price_output_per_token_nano_usd
                FROM processing_attempt
                WHERE fingerprint = ?
                ORDER BY attempt_number ASC
                """;

        try (Connection connection = getConnection();
             Statement pragmaStmt = connection.createStatement();
             PreparedStatement stmt = connection.prepareStatement(sql)) {

            pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
            stmt.setString(1, fingerprint.sha256Hex());

            try (ResultSet rs = stmt.executeQuery()) {
                List<ProcessingAttempt> attempts = new ArrayList<>();
                while (rs.next()) {
                    attempts.add(mapToProcessingAttempt(rs));
                }
                return List.copyOf(attempts);
            }

        } catch (SQLException e) {
            String message = "Verarbeitungsversuche konnten nicht geladen werden für Fingerprint '"
                    + fingerprint.sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    // -------------------------------------------------------------------------
    // Mapping-Hilfsmethoden
    // -------------------------------------------------------------------------

    /**
     * Bildet eine ResultSet-Zeile auf eine {@link DocumentHistoryRow} ab.
     *
     * @param rs das ResultSet, positioniert auf der aktuellen Zeile
     * @return die gemappte Zeile; nie {@code null}
     * @throws SQLException bei JDBC-Lesefehlern
     */
    private DocumentHistoryRow mapToDocumentHistoryRow(ResultSet rs) throws SQLException {
        String fpHex = rs.getString("fingerprint");
        String statusStr = rs.getString("overall_status");
        String sourceFileName = rs.getString("last_known_source_file_name");
        String targetFileName = rs.getString("last_target_file_name"); // nullable
        String sourcePath = rs.getString("last_known_source_locator");
        String updatedAtStr = rs.getString("updated_at");
        long attemptCount = rs.getLong("attempt_count");

        return new DocumentHistoryRow(
                new DocumentFingerprint(fpHex),
                ProcessingStatus.valueOf(statusStr),
                sourceFileName,
                targetFileName,
                sourcePath,
                stringToInstant(updatedAtStr),
                attemptCount);
    }

    /**
     * Bildet eine ResultSet-Zeile auf einen {@link DocumentRecord} ab.
     *
     * @param rs          das ResultSet, positioniert auf der aktuellen Zeile
     * @param fingerprint der Fingerprint, der bereits bekannt ist
     * @return der gemappte Stammsatz; nie {@code null}
     * @throws SQLException bei JDBC-Lesefehlern
     */
    private DocumentRecord mapToDocumentRecord(ResultSet rs, DocumentFingerprint fingerprint) throws SQLException {
        return new DocumentRecord(
                fingerprint,
                new SourceDocumentLocator(rs.getString("last_known_source_locator")),
                rs.getString("last_known_source_file_name"),
                ProcessingStatus.valueOf(rs.getString("overall_status")),
                new FailureCounters(
                        rs.getInt("content_error_count"),
                        rs.getInt("transient_error_count")),
                stringToInstant(rs.getString("last_failure_instant")),
                stringToInstant(rs.getString("last_success_instant")),
                stringToInstant(rs.getString("created_at")),
                stringToInstant(rs.getString("updated_at")),
                rs.getString("last_target_path"),
                rs.getString("last_target_file_name"));
    }

    /**
     * Bildet eine ResultSet-Zeile auf einen {@link ProcessingAttempt} ab.
     *
     * @param rs das ResultSet, positioniert auf der aktuellen Zeile
     * @return der gemappte Versuch; nie {@code null}
     * @throws SQLException bei JDBC-Lesefehlern
     */
    private ProcessingAttempt mapToProcessingAttempt(ResultSet rs) throws SQLException {
        String resolvedDateStr = rs.getString("resolved_date");
        LocalDate resolvedDate = resolvedDateStr != null ? LocalDate.parse(resolvedDateStr) : null;

        String dateSourceStr = rs.getString("date_source");
        DateSource dateSource = dateSourceStr != null ? DateSource.valueOf(dateSourceStr) : null;

        int processedPageCountRaw = rs.getInt("processed_page_count");
        Integer processedPageCount = rs.wasNull() ? null : processedPageCountRaw;

        int sentCharacterCountRaw = rs.getInt("sent_character_count");
        Integer sentCharacterCount = rs.wasNull() ? null : sentCharacterCountRaw;

        return new ProcessingAttempt(
                new DocumentFingerprint(rs.getString("fingerprint")),
                new RunId(rs.getString("run_id")),
                rs.getInt("attempt_number"),
                stringToInstant(rs.getString("started_at")),
                stringToInstant(rs.getString("ended_at")),
                ProcessingStatus.valueOf(rs.getString("status")),
                rs.getString("failure_class"),
                rs.getString("failure_message"),
                rs.getBoolean("retryable"),
                rs.getString("ai_provider"),
                rs.getString("model_name"),
                rs.getString("prompt_identifier"),
                processedPageCount,
                sentCharacterCount,
                rs.getString("ai_raw_response"),
                rs.getString("ai_reasoning"),
                resolvedDate,
                dateSource,
                rs.getString("validated_title"),
                rs.getString("final_target_file_name"),
                readNullableLong(rs, "input_tokens"),
                readNullableLong(rs, "output_tokens"),
                readNullableLong(rs, "cache_creation_input_tokens"),
                readNullableLong(rs, "cache_read_input_tokens"),
                readNullableLong(rs, "price_input_per_token_nano_usd"),
                readNullableLong(rs, "price_output_per_token_nano_usd"));
    }

    /**
     * Liest einen nullable {@link Long}-Wert aus einer Spalte.
     *
     * @param rs         das ResultSet
     * @param columnName Spaltenname
     * @return Wert oder {@code null}
     * @throws SQLException bei JDBC-Lesefehlern
     */
    private static Long readNullableLong(ResultSet rs, String columnName) throws SQLException {
        long value = rs.getLong(columnName);
        return rs.wasNull() ? null : value;
    }

    // -------------------------------------------------------------------------
    // SQL-LIKE Escaping
    // -------------------------------------------------------------------------

    /**
     * Escaped Sonderzeichen {@code %} und {@code _} in einer LIKE-Eingabe mit {@code \}.
     * <p>
     * Der Escape-Charakter {@code \} muss in der SQL-Abfrage als
     * {@code ESCAPE '\'} angegeben werden.
     *
     * @param input die rohe Benutzereingabe; darf nicht {@code null} sein
     * @return der escaped String; nie {@code null}
     */
    private static String escapeSqlLike(String input) {
        return input
                .replace("\\", "\\\\")
                .replace("%", "\\%")
                .replace("_", "\\_");
    }

    // -------------------------------------------------------------------------
    // JDBC-Hilfsmethoden
    // -------------------------------------------------------------------------

    /**
     * Öffnet eine neue Datenbankverbindung zur konfigurierten SQLite-Datei.
     * <p>
     * Kann in Unterklassen überschrieben werden, um eine gemeinsam genutzte
     * Transaktions-Verbindung bereitzustellen.
     *
     * @return eine neue Datenbankverbindung
     * @throws SQLException wenn die Verbindung nicht hergestellt werden kann
     */
    protected Connection getConnection() throws SQLException {
        return SqliteConnectionFactory.open(jdbcUrl);
    }

    /**
     * Parst einen Instant aus einer String-Darstellung.
     * <p>
     * Unterstützt ISO-8601 (modern) und das Legacy-Format {@code yyyy-MM-dd HH:mm:ss} (UTC).
     *
     * @param stringValue die String-Darstellung; kann {@code null} sein
     * @return das geparste Instant, oder {@code null} wenn die Eingabe leer oder nicht parsbar ist
     */
    private Instant stringToInstant(String stringValue) {
        if (stringValue == null || stringValue.isBlank()) {
            return null;
        }

        try {
            return Instant.parse(stringValue);
        } catch (Exception e) {
            try {
                LocalDateTime dateTime = LocalDateTime.parse(stringValue,
                        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                return dateTime.atZone(ZoneId.of("UTC")).toInstant();
            } catch (Exception fallback) {
                logger.warn("Instant konnte nicht geparst werden '{}': {}", stringValue, fallback.getMessage());
                return null;
            }
        }
    }
}
