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.sql.Types;
import java.time.format.DateTimeFormatter;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

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.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
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;

/**
 * SQLite implementation of {@link ProcessingAttemptRepository}.
 * <p>
 * Provides CRUD operations for the processing attempt history (Versuchshistorie)
 * including all AI traceability fields added during schema evolution.
 * <p>
 * <strong>Schema compatibility:</strong> This adapter writes all columns including
 * the AI traceability columns and the provider-identifier column ({@code ai_provider}).
 * When reading rows that were written before schema evolution, those columns contain
 * {@code NULL} and are mapped to {@code null} in the Java record.
 * <p>
 * <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
 * confined to this class. No JDBC types appear in the port interface or in any
 * application/domain type.
 */
public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttemptRepository {
    private static final String FINGERPRINT_NOT_NULL = "fingerprint must not be null";


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

    private final String jdbcUrl;

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

    /**
     * Constructs the adapter with the JDBC URL of the SQLite database file.
     *
     * @param jdbcUrl the JDBC URL of the SQLite database; must not be null or blank
     * @throws NullPointerException     if {@code jdbcUrl} is null
     * @throws IllegalArgumentException if {@code jdbcUrl} is blank
     */
    public SqliteProcessingAttemptRepositoryAdapter(String jdbcUrl) {
        Objects.requireNonNull(jdbcUrl, "jdbcUrl must not be null");
        if (jdbcUrl.isBlank()) {
            throw new IllegalArgumentException("jdbcUrl must not be blank");
        }
        this.jdbcUrl = jdbcUrl;
    }

    /**
     * Returns the attempt number to assign to the <em>next</em> attempt for the given
     * fingerprint.
     * <p>
     * If no prior attempts exist for the fingerprint, returns&nbsp;1.
     * Otherwise returns the current maximum attempt number plus&nbsp;1.
     *
     * @param fingerprint the document identity; must not be null
     * @return the next monotonic attempt number; always &gt;= 1
     * @throws DocumentPersistenceException if the query fails due to a technical error
     */
    @Override
    public int loadNextAttemptNumber(DocumentFingerprint fingerprint) {
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);

        String sql = """
                SELECT COALESCE(MAX(attempt_number), 0) + 1 AS next_attempt_number
                FROM processing_attempt
                WHERE fingerprint = ?
                """;

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

            try (Statement pragmaStmt = connection.createStatement()) {
                pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
            }

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

            try (ResultSet rs = statement.executeQuery()) {
                if (rs.next()) {
                    return rs.getInt("next_attempt_number");
                } else {
                    return 1;
                }
            }

        } catch (SQLException e) {
            String message = "Failed to load next attempt number for fingerprint '"
                    + fingerprint.sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    /**
     * Persists exactly one processing attempt record including all AI traceability fields.
     *
     * @param attempt the attempt to persist; must not be null
     * @throws DocumentPersistenceException if the insert fails due to a technical error
     */
    @Override
    public void save(ProcessingAttempt attempt) {
        Objects.requireNonNull(attempt, "attempt must not be null");

        String sql = """
                INSERT INTO processing_attempt (
                    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
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                """;

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

            pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);

            statement.setString(1, attempt.fingerprint().sha256Hex());
            statement.setString(2, attempt.runId().value());
            statement.setInt(3, attempt.attemptNumber());
            statement.setString(4, attempt.startedAt().toString());
            statement.setString(5, attempt.endedAt().toString());
            statement.setString(6, attempt.status().name());
            setNullableString(statement, 7, attempt.failureClass());
            // 1000-Zeichen-Grenze erzwingen; längere Meldungen werden mit „…" markiert
            setNullableString(statement, 8, truncateFailureMessage(attempt.failureMessage()));
            statement.setBoolean(9, attempt.retryable());
            // AI provider identifier and AI traceability fields
            setNullableString(statement, 10, attempt.aiProvider());
            setNullableString(statement, 11, attempt.modelName());
            setNullableString(statement, 12, attempt.promptIdentifier());
            setNullableInteger(statement, 13, attempt.processedPageCount());
            setNullableInteger(statement, 14, attempt.sentCharacterCount());
            setNullableString(statement, 15, attempt.aiRawResponse());
            setNullableString(statement, 16, attempt.aiReasoning());
            setNullableString(statement, 17,
                    attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
            setNullableString(statement, 18,
                    attempt.dateSource() != null ? attempt.dateSource().name() : null);
            setNullableString(statement, 19, attempt.validatedTitle());
            setNullableString(statement, 20, attempt.finalTargetFileName());
            // Token- und Preis-Snapshot-Felder; alle nullable
            setNullableLong(statement, 21, attempt.inputTokens());
            setNullableLong(statement, 22, attempt.outputTokens());
            setNullableLong(statement, 23, attempt.cacheCreationInputTokens());
            setNullableLong(statement, 24, attempt.cacheReadInputTokens());
            setNullableLong(statement, 25, attempt.priceInputPerTokenNanoUsd());
            setNullableLong(statement, 26, attempt.priceOutputPerTokenNanoUsd());

            int rowsAffected = statement.executeUpdate();
            if (rowsAffected != 1) {
                throw new DocumentPersistenceException(
                        "Expected to insert 1 row but affected " + rowsAffected + " rows");
            }

            logger.debug("Saved processing attempt #{} for fingerprint: {}",
                    attempt.attemptNumber(), attempt.fingerprint().sha256Hex());

        } catch (SQLException e) {
            String message = "Failed to save processing attempt #" + attempt.attemptNumber()
                    + " for fingerprint '" + attempt.fingerprint().sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    /**
     * Returns all historised attempts for the given fingerprint, ordered by
     * {@link ProcessingAttempt#attemptNumber()} ascending.
     *
     * @param fingerprint the document identity; must not be null
     * @return immutable list of attempts; never null
     * @throws DocumentPersistenceException if the query fails
     */
    @Override
    public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);

        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
                FROM processing_attempt
                WHERE fingerprint = ?
                ORDER BY attempt_number ASC
                """;

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

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

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

        } catch (SQLException e) {
            String message = "Failed to find processing attempts for fingerprint '"
                    + fingerprint.sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    /**
     * Returns the most recent attempt with status {@code PROPOSAL_READY} for the given
     * fingerprint, or {@code null} if no such attempt exists.
     * <p>
     * This is the <em>leading source</em> for the naming proposal: the most recent
     * {@code PROPOSAL_READY} attempt carries the validated date, title, and reasoning
     * that subsequent processing steps consume.
     *
     * @param fingerprint the document identity; must not be null
     * @return the most recent {@code PROPOSAL_READY} attempt, or {@code null}
     * @throws DocumentPersistenceException if the query fails
     */
    @Override
    public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);

        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
                FROM processing_attempt
                WHERE fingerprint = ?
                  AND status = ?
                ORDER BY attempt_number DESC
                LIMIT 1
                """;

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

            pragmaStmt.execute(PRAGMA_FOREIGN_KEYS_ON);
            statement.setString(1, fingerprint.sha256Hex());
            statement.setString(2, ProcessingStatus.PROPOSAL_READY.name());

            try (ResultSet rs = statement.executeQuery()) {
                if (rs.next()) {
                    return mapResultSetToProcessingAttempt(rs);
                }
                return null;
            }

        } catch (SQLException e) {
            String message = "Failed to find latest PROPOSAL_READY attempt for fingerprint '"
                    + fingerprint.sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    // -------------------------------------------------------------------------
    // Mapping helpers
    // -------------------------------------------------------------------------

    private ProcessingAttempt mapResultSetToProcessingAttempt(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;

        Integer processedPageCount = (Integer) getNullableInt(rs, "processed_page_count");
        Integer sentCharacterCount = (Integer) getNullableInt(rs, "sent_character_count");

        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")
        );
    }

    /**
     * Versucht, einen Instant aus einer String-Darstellung zu parsen.
     * Unterstützt sowohl modernes ISO-8601-Format als auch Legacy-Format.
     *
     * @param stringValue die String-Darstellung des Datums, kann null sein
     * @return das geparste Instant, oder null wenn stringValue null oder leer ist
     */
    private Instant stringToInstant(String stringValue) {
        if (stringValue == null || stringValue.isBlank()) {
            return null;
        }

        // Versuch mit ISO-8601 Format (moderner Standard)
        try {
            return Instant.parse(stringValue);
        } catch (Exception e) {
            // Fallback auf älteres Format "yyyy-MM-dd HH:mm:ss" (als UTC)
            try {
                LocalDateTime dateTime = LocalDateTime.parse(stringValue,
                        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
                return dateTime.atZone(ZoneId.of("UTC")).toInstant();
            } catch (Exception fallbackException) {
                logger.warn("Fehler beim Parsen der Instant-String '{}' in beiden Formaten (ISO-8601 und Legacy-Format)",
                        stringValue, fallbackException);
                return null;
            }
        }
    }

    // -------------------------------------------------------------------------
    // Hilfsmethoden
    // -------------------------------------------------------------------------

    /**
     * Kürzt eine Fehlermeldung auf maximal 1000 Zeichen vor der Persistierung.
     * Längere Meldungen werden mit „…" markiert.
     *
     * @param message die ursprüngliche Fehlermeldung; kann {@code null} sein
     * @return die (ggf. gekürzte) Meldung oder {@code null}
     */
    private static String truncateFailureMessage(String message) {
        if (message == null) {
            return null;
        }
        if (message.length() <= 1000) {
            return message;
        }
        return message.substring(0, 997) + "…";
    }

    // -------------------------------------------------------------------------
    // JDBC nullable helpers
    // -------------------------------------------------------------------------

    private static void setNullableString(PreparedStatement stmt, int index, String value)
            throws SQLException {
        if (value == null) {
            stmt.setNull(index, Types.VARCHAR);
        } else {
            stmt.setString(index, value);
        }
    }

    private static void setNullableInteger(PreparedStatement stmt, int index, Integer value)
            throws SQLException {
        if (value == null) {
            stmt.setNull(index, Types.INTEGER);
        } else {
            stmt.setInt(index, value);
        }
    }

    /**
     * Setzt einen nullable {@link Long}-Wert auf einem PreparedStatement.
     *
     * @param stmt  das Statement
     * @param index 1-basierter Parameter-Index
     * @param value Wert oder {@code null}
     * @throws SQLException bei JDBC-Fehlern
     */
    private static void setNullableLong(PreparedStatement stmt, int index, Long value)
            throws SQLException {
        if (value == null) {
            stmt.setNull(index, Types.BIGINT);
        } else {
            stmt.setLong(index, value);
        }
    }

    private static Object getNullableInt(ResultSet rs, String column) throws SQLException {
        int value = rs.getInt(column);
        return rs.wasNull() ? null : value;
    }

    /**
     * Deletes all attempt history entries for the given fingerprint.
     * <p>
     * Idempotent: if no attempts exist for the fingerprint the method returns without
     * error. A {@link DocumentPersistenceException} is thrown only on technical database
     * failures.
     *
     * @param fingerprint the document identity whose attempt records should be removed;
     *                    must not be null
     * @throws DocumentPersistenceException if the delete fails due to a technical error
     */
    @Override
    public void deleteAllByFingerprint(DocumentFingerprint fingerprint) {
        Objects.requireNonNull(fingerprint, FINGERPRINT_NOT_NULL);

        String sql = "DELETE FROM processing_attempt WHERE fingerprint = ?";

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

            statement.setString(1, fingerprint.sha256Hex());
            int rowsAffected = statement.executeUpdate();
            logger.debug("Deleted {} processing_attempt row(s) for fingerprint: {}",
                    rowsAffected, fingerprint.sha256Hex());

        } catch (SQLException e) {
            String message = "Failed to delete processing attempts for fingerprint '"
                    + fingerprint.sha256Hex() + "': " + e.getMessage();
            logger.error(message, e);
            throw new DocumentPersistenceException(message, e);
        }
    }

    /**
     * Returns the JDBC URL this adapter uses.
     *
     * @return the JDBC URL; never null or blank
     */
    public String getJdbcUrl() {
        return jdbcUrl;
    }

    /**
     * Returns a JDBC connection. May be overridden in tests to provide shared connections.
     */
    protected Connection getConnection() throws SQLException {
        return SqliteConnectionFactory.open(jdbcUrl);
    }
}
