package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;

import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsPort;
import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Properties;
import java.util.regex.Pattern;

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

/**
 * Implementiert {@link ConfigurationFileLockPort} und {@link SchedulerSettingsPort}
 * auf Basis eines gemeinsam genutzten {@link FileChannel}.
 * <p>
 * Der exklusive OS-Lock auf die {@code .properties}-Datei wird über
 * {@link FileChannel#tryLock()} mit einer Deadline-Wiederholschleife erworben.
 * Solange der Lock gehalten wird, erfolgen Schreibvorgänge direkt über
 * den bereits offenen Kanal (Truncate → Position(0) → Write → Force).
 * Ohne aktiven Lock werden Schreibvorgänge über eine temporäre Datei
 * und {@link Files#move} mit {@code ATOMIC_MOVE} und {@code REPLACE_EXISTING}
 * durchgeführt.
 * <p>
 * Beide Ports teilen den internen {@link FileChannel}, damit
 * Settings-Schreibvorgänge auch während eines aktiven OS-Locks korrekt
 * in die Konfigurationsdatei durchgeschrieben werden können.
 * <p>
 * Instanzen dieser Klasse sind <em>nicht</em> Thread-sicher. Der Aufrufer
 * ist für die Serialisierung konkurrierender Zugriffe verantwortlich.
 */
public class FileChannelConfigurationAccessAdapter
        implements ConfigurationFileLockPort, SchedulerSettingsPort {

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

    private static final long ACQUIRE_TIMEOUT_MS = 3000L;
    private static final long ACQUIRE_RETRY_INTERVAL_MS = 100L;

    private static final String KEY_INTERVAL = "scheduler.interval.seconds";

    private final Path configFile;

    private FileChannel channel;
    private FileLock fileLock;

    /**
     * Erstellt einen neuen Adapter für die angegebene Konfigurationsdatei.
     *
     * @param configFile Pfad zur {@code .properties}-Konfigurationsdatei;
     *                   darf nicht {@code null} sein
     */
    public FileChannelConfigurationAccessAdapter(Path configFile) {
        this.configFile = Objects.requireNonNull(configFile, "configFile darf nicht null sein");
    }

    // -------------------------------------------------------------------------
    // ConfigurationFileLockPort
    // -------------------------------------------------------------------------

    /**
     * Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei.
     * <p>
     * Ist der Lock bereits durch diese Instanz gehalten, hat dieser Aufruf
     * keine Wirkung (idempotent). Andernfalls wird der {@link FileChannel}
     * mit {@link StandardOpenOption#READ} und {@link StandardOpenOption#WRITE}
     * geöffnet und {@link FileChannel#tryLock()} in einer Schleife mit
     * {@value ACQUIRE_RETRY_INTERVAL_MS}-ms-Pausen versucht. Schlägt der
     * Erwerb innerhalb von {@value ACQUIRE_TIMEOUT_MS} ms fehl, werden
     * Kanal und Lock geschlossen und eine {@link ConfigurationFileLockException}
     * geworfen.
     *
     * @throws ConfigurationFileLockException wenn der Lock nicht innerhalb der
     *         Deadline erworben werden kann, ein I/O-Fehler auftritt oder der
     *         Thread unterbrochen wird
     */
    @Override
    public void acquireLock() throws ConfigurationFileLockException {
        if (isLocked()) {
            return;
        }
        long deadline = System.currentTimeMillis() + ACQUIRE_TIMEOUT_MS;
        try {
            channel = FileChannel.open(configFile,
                    StandardOpenOption.READ, StandardOpenOption.WRITE);
            while (true) {
                try {
                    FileLock lock = channel.tryLock();
                    if (lock != null) {
                        this.fileLock = lock;
                        logger.debug("OS-Lock auf Konfigurationsdatei erworben: {}", configFile);
                        return;
                    }
                } catch (OverlappingFileLockException e) {
                    // Dieselbe JVM hält bereits einen Lock auf diesen Dateibereich;
                    // wird wie ein nicht verfügbarer Lock behandelt.
                }
                if (System.currentTimeMillis() >= deadline) {
                    closeChannelSilently();
                    throw new ConfigurationFileLockException(
                            "Konfigurationsdatei konnte nicht gesperrt werden: "
                            + "Timeout nach " + ACQUIRE_TIMEOUT_MS + " ms. Datei: " + configFile);
                }
                Thread.sleep(ACQUIRE_RETRY_INTERVAL_MS);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            closeChannelSilently();
            throw new ConfigurationFileLockException(
                    "Lock-Erwerb auf Konfigurationsdatei wurde unterbrochen.", e);
        } catch (IOException e) {
            closeChannelSilently();
            throw new ConfigurationFileLockException(
                    "Konfigurationsdatei konnte nicht geöffnet oder gesperrt werden: "
                    + configFile, e);
        }
    }

    /**
     * Gibt den exklusiven Lock frei und schließt den {@link FileChannel}.
     * <p>
     * Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent).
     * Aufgetretene I/O-Fehler werden geloggt und still übergangen.
     */
    @Override
    public void releaseLock() {
        if (fileLock != null) {
            try {
                fileLock.release();
                logger.debug("OS-Lock auf Konfigurationsdatei freigegeben: {}", configFile);
            } catch (IOException e) {
                logger.warn("Fehler beim Freigeben des FileLock für {}.", configFile, e);
            }
            fileLock = null;
        }
        closeChannelSilently();
    }

    /**
     * Prüft, ob der Lock aktuell von dieser Instanz gehalten wird.
     *
     * @return {@code true}, wenn der Lock aktiv und gültig ist
     */
    @Override
    public boolean isLocked() {
        return fileLock != null && fileLock.isValid();
    }

    // -------------------------------------------------------------------------
    // SchedulerSettingsPort
    // -------------------------------------------------------------------------

    /**
     * Liest die aktuellen Scheduler-Einstellungen aus der Konfigurationsdatei.
     * <p>
     * Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert aus
     * {@link SchedulerSettings#defaults()} zurückgegeben. Ungültige Werte
     * (z.B. nicht-numerisches Intervall) führen ebenfalls zu den Standardwerten,
     * nicht zu einer Exception.
     *
     * @return aktuelle Scheduler-Einstellungen; nie {@code null}
     */
    @Override
    public SchedulerSettings loadSettings() {
        Properties props = new Properties();
        try {
            String content = Files.readString(configFile, StandardCharsets.UTF_8);
            props.load(new StringReader(content));
        } catch (IOException e) {
            logger.warn("Scheduler-Einstellungen konnten nicht geladen werden, "
                    + "Standardwerte werden verwendet. Datei: {}", configFile, e);
            return SchedulerSettings.defaults();
        }
        int intervalSeconds = parseInterval(props.getProperty(KEY_INTERVAL));
        return new SchedulerSettings(intervalSeconds);
    }

    /**
     * Schreibt den Wert von {@code scheduler.interval.seconds} in die
     * Konfigurationsdatei.
     * <p>
     * Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key
     * noch nicht, wird er am Ende der Datei ergänzt.
     *
     * @param seconds neues Intervall in Sekunden
     * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
     */
    @Override
    public void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException {
        updateProperty(KEY_INTERVAL, String.valueOf(seconds));
    }

    // -------------------------------------------------------------------------
    // Hilfsmethoden: Parsen
    // -------------------------------------------------------------------------

    private int parseInterval(String raw) {
        if (raw == null || raw.isBlank()) {
            return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
        }
        try {
            return Integer.parseInt(raw.trim());
        } catch (NumberFormatException e) {
            return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
        }
    }

    // -------------------------------------------------------------------------
    // Hilfsmethoden: format-erhaltende Schreiblogik
    // -------------------------------------------------------------------------

    private void updateProperty(String key, String value) throws SchedulerSettingsWriteException {
        try {
            byte[] rawBytes = isLocked() ? readAllBytesViaChannel() : Files.readAllBytes(configFile);
            String separator = detectLineSeparator(rawBytes);
            String rawContent = new String(rawBytes, StandardCharsets.UTF_8);
            List<String> lines = splitLines(rawContent, separator);
            updateOrAppend(lines, key, value);
            String newContent = String.join(separator, lines);
            writeContent(newContent);
        } catch (IOException e) {
            throw new SchedulerSettingsWriteException(
                    "Einstellung '" + key + "' konnte nicht in "
                    + configFile + " geschrieben werden.", e);
        }
    }

    /**
     * Liest den vollständigen Dateiinhalt über den gemeinsamen {@link FileChannel}.
     * Wird verwendet, wenn ein OS-Lock aktiv ist und {@link Files#readAllBytes} auf
     * Windows die gesperrte Datei nicht öffnen kann.
     */
    private byte[] readAllBytesViaChannel() throws IOException {
        long fileSize = channel.size();
        channel.position(0);
        ByteArrayOutputStream out = new ByteArrayOutputStream((int) Math.max(fileSize, 0));
        ByteBuffer buf = ByteBuffer.allocate(8192);
        while (channel.read(buf) != -1) {
            buf.flip();
            out.write(buf.array(), 0, buf.limit());
            buf.clear();
        }
        return out.toByteArray();
    }

    /**
     * Erkennt das Zeilentrennzeichen anhand der ersten vorkommenden Byte-Sequenz.
     * Findet die Methode {@code \r\n}, wird {@code "\r\n"} zurückgegeben;
     * andernfalls {@code "\n"}.
     */
    private String detectLineSeparator(byte[] rawContent) {
        for (int i = 0; i < rawContent.length - 1; i++) {
            if (rawContent[i] == '\r' && rawContent[i + 1] == '\n') {
                return "\r\n";
            }
        }
        return "\n";
    }

    private List<String> splitLines(String content, String separator) {
        String[] parts = content.split(Pattern.quote(separator), -1);
        return new ArrayList<>(Arrays.asList(parts));
    }

    /**
     * Sucht die erste Zeile, die den angegebenen Key definiert, und ersetzt den
     * Wert. Wird keine passende Zeile gefunden, wird der Key am Ende der Datei
     * eingefügt – unmittelbar vor einer abschließenden Leerzeile, sofern vorhanden.
     */
    private void updateOrAppend(List<String> lines, String key, String value) {
        for (int i = 0; i < lines.size(); i++) {
            if (isKeyLine(lines.get(i), key)) {
                lines.set(i, key + "=" + value);
                return;
            }
        }
        // Key nicht gefunden: vor abschließender Leerzeile einfügen, sonst anhängen.
        if (!lines.isEmpty() && lines.get(lines.size() - 1).isBlank()) {
            lines.add(lines.size() - 1, key + "=" + value);
        } else {
            lines.add(key + "=" + value);
        }
    }

    /**
     * Prüft, ob die Zeile eine Property-Definition für genau den angegebenen Key
     * darstellt. Kommentarzeilen (beginnend mit {@code #} oder {@code !}) werden
     * immer als nicht-passend bewertet.
     */
    private boolean isKeyLine(String line, String key) {
        String trimmed = line.stripLeading();
        if (trimmed.startsWith("#") || trimmed.startsWith("!")) {
            return false;
        }
        if (!trimmed.startsWith(key)) {
            return false;
        }
        int afterKey = key.length();
        if (afterKey >= trimmed.length()) {
            return false; // Zeile enthält nur den Schlüssel ohne Trennzeichen
        }
        char next = trimmed.charAt(afterKey);
        return next == '=' || next == ':' || Character.isWhitespace(next);
    }

    /**
     * Schreibt den Inhalt in die Konfigurationsdatei.
     * <p>
     * Ist der OS-Lock aktiv, wird über den gemeinsamen {@link FileChannel}
     * geschrieben (Truncate → Position(0) → Write → Force). Ist kein Lock aktiv,
     * wird eine temporäre Datei erzeugt und danach atomar verschoben.
     */
    private void writeContent(String content) throws IOException {
        byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
        if (isLocked()) {
            channel.truncate(0);
            channel.position(0);
            ByteBuffer buffer = ByteBuffer.wrap(bytes);
            while (buffer.hasRemaining()) {
                channel.write(buffer);
            }
            channel.force(true);
        } else {
            Path tempFile = configFile.resolveSibling(configFile.getFileName() + ".tmp");
            Files.writeString(tempFile, content, StandardCharsets.UTF_8,
                    StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
            Files.move(tempFile, configFile,
                    StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
        }
    }

    private void closeChannelSilently() {
        if (channel != null) {
            try {
                channel.close();
            } catch (IOException e) {
                logger.warn("Fehler beim Schließen des FileChannel für {}.", configFile, e);
            }
            channel = null;
        }
    }
}
