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.time.Instant;
import java.time.format.DateTimeParseException;
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.dto.ModelPriceChangeSet;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceEntry;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceKey;
import de.gecheckt.pdf.umbenenner.application.dto.ModelPriceView;
import de.gecheckt.pdf.umbenenner.application.port.out.ModelPriceRepository;

/**
 * SQLite-Implementierung des {@link ModelPriceRepository}.
 *
 * <p>Persistiert Modell-Preise in der Tabelle {@code model_price}. Inserts und
 * Updates erfolgen via {@code INSERT ... ON CONFLICT(provider, model_name)
 * DO UPDATE SET ...}; Loeschungen via direktes {@code DELETE}. Der
 * {@link #saveAllChanges(ModelPriceChangeSet) Batch-Pfad} faehrt eine
 * JDBC-Transaktion mit {@code autoCommit=false}, ROLLBACKt bei Fehlern und
 * COMMITet bei Erfolg.
 *
 * <p>Beim Lesen wird der DB-String {@code updated_at} als {@link Instant}
 * geparst. Schlaegt das Parsing fehl, liefert die Adapter-Methode einen
 * {@link ModelPriceView} mit {@code updatedAt=null} und
 * {@code invalidUpdatedAt=true}; der Originalstring landet in
 * {@code invalidUpdatedAtRaw}, damit GUI/CLI &quot;ung&uuml;ltig&quot; anzeigen
 * koennen.
 */
public class SqliteModelPriceRepositoryAdapter implements ModelPriceRepository {

    private static final Logger LOG = LogManager.getLogger(SqliteModelPriceRepositoryAdapter.class);

    private static final String SQL_FIND_ALL = """
            SELECT provider, model_name,
                   price_input_per_token_nano_usd, price_output_per_token_nano_usd,
                   currency, updated_at
              FROM model_price
             ORDER BY provider, model_name
            """;

    private static final String SQL_FIND_BY_KEY = """
            SELECT provider, model_name,
                   price_input_per_token_nano_usd, price_output_per_token_nano_usd,
                   currency, updated_at
              FROM model_price
             WHERE provider = ? AND model_name = ?
            """;

    private static final String SQL_UPSERT = """
            INSERT INTO model_price
                (provider, model_name,
                 price_input_per_token_nano_usd, price_output_per_token_nano_usd,
                 currency, updated_at)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(provider, model_name) DO UPDATE SET
                price_input_per_token_nano_usd  = excluded.price_input_per_token_nano_usd,
                price_output_per_token_nano_usd = excluded.price_output_per_token_nano_usd,
                currency                        = excluded.currency,
                updated_at                      = excluded.updated_at
            """;

    private static final String SQL_DELETE = """
            DELETE FROM model_price WHERE provider = ? AND model_name = ?
            """;

    private final String jdbcUrl;

    /**
     * Erzeugt den Adapter mit der JDBC-URL der Ziel-Datenbank.
     *
     * @param jdbcUrl JDBC-URL der SQLite-Datenbank; weder {@code null} noch leer
     */
    public SqliteModelPriceRepositoryAdapter(String jdbcUrl) {
        Objects.requireNonNull(jdbcUrl, "jdbcUrl");
        if (jdbcUrl.isBlank()) {
            throw new IllegalArgumentException("jdbcUrl darf nicht leer sein");
        }
        this.jdbcUrl = jdbcUrl;
    }

    /**
     * Oeffnet eine neue Connection ueber die zentrale Connection-Factory.
     *
     * @return eingerichtete Connection
     * @throws SQLException wenn der Verbindungsaufbau fehlschlaegt
     */
    protected Connection getConnection() throws SQLException {
        return SqliteConnectionFactory.open(jdbcUrl);
    }

    @Override
    public List<ModelPriceView> findAll() {
        List<ModelPriceView> result = new ArrayList<>();
        try (Connection connection = getConnection();
                PreparedStatement statement = connection.prepareStatement(SQL_FIND_ALL);
                ResultSet rs = statement.executeQuery()) {
            while (rs.next()) {
                result.add(mapRow(rs));
            }
        } catch (SQLException e) {
            throw new ModelPriceRepositoryException(
                    "Modell-Preise konnten nicht gelesen werden: " + e.getMessage(), e);
        }
        return List.copyOf(result);
    }

    @Override
    public Optional<ModelPriceView> findByProviderAndModelName(String provider, String modelName) {
        Objects.requireNonNull(provider, "provider");
        Objects.requireNonNull(modelName, "modelName");
        try (Connection connection = getConnection();
                PreparedStatement statement = connection.prepareStatement(SQL_FIND_BY_KEY)) {
            statement.setString(1, provider);
            statement.setString(2, modelName);
            try (ResultSet rs = statement.executeQuery()) {
                if (rs.next()) {
                    return Optional.of(mapRow(rs));
                }
                return Optional.empty();
            }
        } catch (SQLException e) {
            throw new ModelPriceRepositoryException(
                    "Modell-Preis-Lookup fehlgeschlagen fuer (" + provider + ", " + modelName
                            + "): " + e.getMessage(), e);
        }
    }

    @Override
    public void upsert(ModelPriceEntry entry) {
        Objects.requireNonNull(entry, "entry");
        try (Connection connection = getConnection();
                PreparedStatement statement = connection.prepareStatement(SQL_UPSERT)) {
            bindUpsert(statement, entry);
            statement.executeUpdate();
        } catch (SQLException e) {
            throw new ModelPriceRepositoryException(
                    "Modell-Preis-Upsert fehlgeschlagen: " + e.getMessage(), e);
        }
    }

    @Override
    public void delete(String provider, String modelName) {
        Objects.requireNonNull(provider, "provider");
        Objects.requireNonNull(modelName, "modelName");
        try (Connection connection = getConnection();
                PreparedStatement statement = connection.prepareStatement(SQL_DELETE)) {
            statement.setString(1, provider);
            statement.setString(2, modelName);
            statement.executeUpdate();
        } catch (SQLException e) {
            throw new ModelPriceRepositoryException(
                    "Modell-Preis-Delete fehlgeschlagen: " + e.getMessage(), e);
        }
    }

    @Override
    public void saveAllChanges(ModelPriceChangeSet changeSet) {
        Objects.requireNonNull(changeSet, "changeSet");
        if (changeSet.isEmpty()) {
            return;
        }
        try (Connection connection = getConnection()) {
            connection.setAutoCommit(false);
            try {
                if (!changeSet.upserts().isEmpty()) {
                    try (PreparedStatement upsertStmt = connection.prepareStatement(SQL_UPSERT)) {
                        for (ModelPriceEntry entry : changeSet.upserts()) {
                            bindUpsert(upsertStmt, entry);
                            upsertStmt.executeUpdate();
                        }
                    }
                }
                if (!changeSet.deletions().isEmpty()) {
                    try (PreparedStatement deleteStmt = connection.prepareStatement(SQL_DELETE)) {
                        for (ModelPriceKey key : changeSet.deletions()) {
                            deleteStmt.setString(1, key.provider());
                            deleteStmt.setString(2, key.modelName());
                            deleteStmt.executeUpdate();
                        }
                    }
                }
                connection.commit();
            } catch (SQLException txError) {
                try {
                    connection.rollback();
                } catch (SQLException rollbackError) {
                    LOG.error("Rollback nach Modell-Preis-Batch-Fehler ebenfalls fehlgeschlagen: {}",
                            rollbackError.getMessage(), rollbackError);
                }
                throw new ModelPriceRepositoryException(
                        "Modell-Preis-Batch konnte nicht persistiert werden: " + txError.getMessage(),
                        txError);
            }
        } catch (SQLException e) {
            throw new ModelPriceRepositoryException(
                    "Datenbankverbindung fuer Modell-Preis-Batch fehlgeschlagen: " + e.getMessage(), e);
        }
    }

    private static void bindUpsert(PreparedStatement statement, ModelPriceEntry entry) throws SQLException {
        statement.setString(1, entry.provider());
        statement.setString(2, entry.modelName());
        statement.setLong(3, entry.priceInputPerTokenNanoUsd());
        statement.setLong(4, entry.priceOutputPerTokenNanoUsd());
        statement.setString(5, entry.currency());
        statement.setString(6, entry.updatedAt().toString());
    }

    /**
     * Liest eine Zeile in einen {@link ModelPriceView}.
     *
     * <p>Bei nicht parsebarem {@code updated_at} wird ein WARN-Log erzeugt
     * und der View mit {@code updatedAt=null}, {@code invalidUpdatedAt=true}
     * sowie dem Originalstring zurueckgegeben.
     *
     * @param rs aktueller ResultSet, dessen Cursor auf der Zielzeile steht
     * @return Lesen-DTO
     * @throws SQLException bei JDBC-Fehlern
     */
    private static ModelPriceView mapRow(ResultSet rs) throws SQLException {
        String provider = rs.getString("provider");
        String modelName = rs.getString("model_name");
        long priceIn = rs.getLong("price_input_per_token_nano_usd");
        long priceOut = rs.getLong("price_output_per_token_nano_usd");
        String currency = rs.getString("currency");
        String updatedAtRaw = rs.getString("updated_at");

        Instant updatedAt = null;
        boolean invalid = false;
        String invalidRaw = null;
        try {
            updatedAt = Instant.parse(updatedAtRaw);
        } catch (DateTimeParseException ex) {
            LOG.warn("updated_at konnte fuer (Provider={}, Modell={}) nicht geparst werden: \"{}\"",
                    provider, modelName, updatedAtRaw);
            invalid = true;
            invalidRaw = updatedAtRaw;
        } catch (NullPointerException ex) {
            LOG.warn("updated_at war fuer (Provider={}, Modell={}) NULL – als ungueltig markiert",
                    provider, modelName);
            invalid = true;
            invalidRaw = null;
        }

        return new ModelPriceView(provider, modelName, priceIn, priceOut, currency, updatedAt,
                invalidRaw, invalid);
    }
}
