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

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;

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

import de.gecheckt.pdf.umbenenner.application.port.out.ExistingIdenticalTargetFile;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;

/**
 * Filesystem-based implementation of {@link TargetFolderPort}.
 * <p>
 * Resolves unique filenames for the configured target folder by checking for existing
 * files and appending a numeric collision-avoidance suffix when necessary.
 *
 * <h2>Duplicate resolution algorithm</h2>
 * <p>
 * Given a base name such as {@code 2024-01-15 - Rechnung.pdf}, the adapter checks:
 * <ol>
 *   <li>{@code 2024-01-15 - Rechnung.pdf} — if free, return it.</li>
 *   <li>{@code 2024-01-15 - Rechnung(1).pdf} — if free, return it.</li>
 *   <li>{@code 2024-01-15 - Rechnung(2).pdf} — and so on.</li>
 * </ol>
 * The suffix is inserted immediately before {@code .pdf}.
 * The 20-character base-title limit does not apply to the suffix.
 *
 * <h2>Architecture boundary</h2>
 * <p>
 * All NIO operations are confined to this adapter. No {@code Path} or {@code File} types
 * appear in the port interface.
 */
public class FilesystemTargetFolderAdapter implements TargetFolderPort {

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

    /** Maximum number of duplicate suffixes attempted before giving up. */
    private static final int MAX_SUFFIX_ATTEMPTS = 9999;

    private final Path targetFolderPath;

    /**
     * Creates the adapter for the given target folder.
     *
     * @param targetFolderPath the target folder path; must not be null
     * @throws NullPointerException if {@code targetFolderPath} is null
     */
    public FilesystemTargetFolderAdapter(Path targetFolderPath) {
        this.targetFolderPath = Objects.requireNonNull(targetFolderPath, "targetFolderPath must not be null");
    }

    /**
     * Returns the absolute string representation of the target folder path.
     * <p>
     * Used by the application layer as an opaque target-folder locator for persistence.
     *
     * @return absolute path string of the target folder; never null or blank
     */
    @Override
    public String getTargetFolderLocator() {
        return targetFolderPath.toAbsolutePath().toString();
    }

    /**
     * Resolves the first available unique filename in the target folder for the given base name,
     * applying an identical-content shortcut when the base name already exists.
     * <p>
     * Processing order:
     * <ol>
     *   <li>If the base name does not yet exist, return it unchanged as a
     *       {@link ResolvedTargetFilename}.</li>
     *   <li>If the base name exists and its SHA-256 matches {@code sourceFingerprint},
     *       return {@link ExistingIdenticalTargetFile} — the target is already up-to-date.</li>
     *   <li>Otherwise try {@code (1)}, {@code (2)}, etc. until a free name is found and
     *       return it as a {@link ResolvedTargetFilename}.</li>
     * </ol>
     *
     * @param baseName          the desired filename including {@code .pdf} extension;
     *                          must not be null or blank
     * @param sourceFingerprint the SHA-256 fingerprint of the source document; must not be null
     * @return a {@link ResolvedTargetFilename}, {@link ExistingIdenticalTargetFile}, or
     *         {@link TargetFolderTechnicalFailure} if folder access fails
     */
    @Override
    public TargetFilenameResolutionResult resolveUniqueFilename(
            String baseName, DocumentFingerprint sourceFingerprint) {
        Objects.requireNonNull(baseName, "baseName must not be null");
        Objects.requireNonNull(sourceFingerprint, "sourceFingerprint must not be null");

        try {
            Path baseNamePath = targetFolderPath.resolve(baseName);

            // Try without suffix first
            if (!Files.exists(baseNamePath)) {
                logger.debug("Resolved target filename without suffix: '{}'", baseName);
                return new ResolvedTargetFilename(baseName);
            }

            // The base name exists — check for identical content before adding a suffix
            if (isIdenticalContent(baseNamePath, sourceFingerprint)) {
                logger.debug("Target file '{}' already exists with identical content — no new copy needed.",
                        baseName);
                return new ExistingIdenticalTargetFile(baseName);
            }

            // Determine split point: everything before the final ".pdf"
            if (!baseName.toLowerCase().endsWith(".pdf")) {
                return new TargetFolderTechnicalFailure(
                        "Base name does not end with .pdf: '" + baseName + "'");
            }
            String nameWithoutExt = baseName.substring(0, baseName.length() - 4);

            // Try (1), (2), ...
            for (int i = 1; i <= MAX_SUFFIX_ATTEMPTS; i++) {
                String candidate = nameWithoutExt + "(" + i + ").pdf";
                if (!Files.exists(targetFolderPath.resolve(candidate))) {
                    logger.debug("Resolved target filename with suffix ({}): '{}'", i, candidate);
                    return new ResolvedTargetFilename(candidate);
                }
            }

            return new TargetFolderTechnicalFailure(
                    "Too many duplicate files for base name '" + baseName
                            + "': checked up to suffix (" + MAX_SUFFIX_ATTEMPTS + ")");

        } catch (Exception e) {
            String message = "Failed to check target folder for duplicate resolution: " + e.getMessage();
            logger.error(message, e);
            return new TargetFolderTechnicalFailure(message);
        }
    }

    /**
     * Returns {@code true} when the SHA-256 digest of the file at {@code targetPath} matches
     * the hex value in {@code sourceFingerprint}.
     * <p>
     * Any I/O or digest error is treated as non-identical (returns {@code false} and logs
     * at debug level), so the duplicate-suffix path is entered instead.
     *
     * @param targetPath        path of the existing target file to compare
     * @param sourceFingerprint expected SHA-256 hex digest of the source document
     * @return {@code true} if the existing file's SHA-256 equals the source fingerprint
     */
    private boolean isIdenticalContent(Path targetPath, DocumentFingerprint sourceFingerprint) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            try (InputStream in = Files.newInputStream(targetPath)) {
                byte[] buffer = new byte[8192];
                int read;
                while ((read = in.read(buffer)) != -1) {
                    digest.update(buffer, 0, read);
                }
            }
            String targetHex = HexFormat.of().formatHex(digest.digest());
            return targetHex.equalsIgnoreCase(sourceFingerprint.sha256Hex());
        } catch (NoSuchAlgorithmException | IOException e) {
            logger.debug("Could not compute SHA-256 of existing target file '{}': {} — "
                    + "treating as non-identical.", targetPath.getFileName(), e.getMessage());
            return false;
        }
    }

    /**
     * Best-effort deletion of a file in the target folder.
     * <p>
     * Used for rollback after a successful copy when subsequent persistence fails.
     * Never throws; all exceptions are caught and logged at warn level.
     *
     * @param resolvedFilename the filename (not full path) to delete; must not be null
     */
    @Override
    public void tryDeleteTargetFile(String resolvedFilename) {
        Objects.requireNonNull(resolvedFilename, "resolvedFilename must not be null");
        try {
            boolean deleted = Files.deleteIfExists(targetFolderPath.resolve(resolvedFilename));
            if (deleted) {
                logger.debug("Best-effort rollback: deleted target file '{}'.", resolvedFilename);
            } else {
                logger.debug("Best-effort rollback: target file '{}' did not exist.", resolvedFilename);
            }
        } catch (IOException e) {
            logger.warn("Best-effort rollback: could not delete target file '{}': {}",
                    resolvedFilename, e.getMessage());
        }
    }
}
