package com.livingworld.core.services;

import com.livingworld.data.saved.SaveMetadata;
import com.livingworld.debug.DiagnosticCategory;
import com.livingworld.debug.LivingWorldLogger;
import com.livingworld.regions.Region;
import com.livingworld.regions.RegionCoordinate;
import com.livingworld.regions.RegionFlags;
import com.livingworld.regions.RegionLifecycleState;
import com.livingworld.regions.RegionMetrics;
import com.livingworld.regions.RegionModuleData;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Clock;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.UUID;

/**
 * File-backed persistence for the Volume 1 region model.
 */
public final class FileRegionPersistenceService implements PersistenceService {

    static final int SCHEMA_VERSION = 1;

    private static final String METADATA_FILE = "metadata.properties";
    private static final String REGIONS_DIRECTORY = "regions";

    private final Path rootDirectory;
    private final Path regionsDirectory;
    private final String modVersion;
    private final Clock clock;
    private final Map<RegionCoordinate, Region> dirtyRegions = new LinkedHashMap<>();
    private SaveMetadata metadata;

    public FileRegionPersistenceService(Path rootDirectory, String modVersion) {
        this(rootDirectory, modVersion, Clock.systemUTC());
    }

    FileRegionPersistenceService(Path rootDirectory, String modVersion, Clock clock) {
        if (rootDirectory == null) {
            throw new IllegalArgumentException("rootDirectory must not be null");
        }
        if (modVersion == null || modVersion.isBlank()) {
            throw new IllegalArgumentException("modVersion must not be blank");
        }
        if (clock == null) {
            throw new IllegalArgumentException("clock must not be null");
        }
        this.rootDirectory = rootDirectory;
        this.regionsDirectory = rootDirectory.resolve(REGIONS_DIRECTORY);
        this.modVersion = modVersion;
        this.clock = clock;
        initializeStorage();
    }

    @Override
    public synchronized void markRegionDirty(Region region) {
        requireRegion(region);
        region.markDirty();
        dirtyRegions.put(region.getCoordinate(), region);
    }

    @Override
    public synchronized void saveDirtyRegions() {
        for (Region region : java.util.List.copyOf(dirtyRegions.values())) {
            saveRegion(region);
        }
        updateMetadata();
    }

    @Override
    public synchronized Optional<Region> loadRegion(RegionCoordinate coordinate) {
        if (coordinate == null) {
            throw new IllegalArgumentException("coordinate must not be null");
        }
        Path file = regionPath(coordinate);
        if (!Files.exists(file)) {
            return Optional.empty();
        }

        try (InputStream input = Files.newInputStream(file)) {
            Properties properties = new Properties();
            properties.load(input);
            Region region = decodeRegion(properties);
            if (!coordinate.equals(region.getCoordinate())) {
                throw new IllegalStateException(
                        "stored coordinate does not match requested coordinate");
            }
            return Optional.of(region);
        } catch (IOException | RuntimeException exception) {
            quarantineCorruptFile(file, exception);
            return Optional.empty();
        }
    }

    @Override
    public synchronized void saveRegion(Region region) {
        requireRegion(region);
        region.validate();

        Properties properties = encodeRegion(region);
        Path target = regionPath(region.getCoordinate());
        Path temporary = target.resolveSibling(target.getFileName() + ".tmp");
        try {
            Files.createDirectories(regionsDirectory);
            try (OutputStream output = Files.newOutputStream(temporary)) {
                properties.store(output, "Living World region data");
            }
            moveAtomically(temporary, target);
            region.clearDirty();
            dirtyRegions.remove(region.getCoordinate());
        } catch (IOException exception) {
            throw new IllegalStateException(
                    "Failed to save region " + region.getCoordinate(), exception);
        } finally {
            tryDelete(temporary);
        }
    }

    @Override
    public synchronized void flushAll() {
        saveDirtyRegions();
    }

    public synchronized int getDirtyRegionCount() {
        return dirtyRegions.size();
    }

    private void initializeStorage() {
        try {
            Files.createDirectories(regionsDirectory);
            metadata = loadMetadata().orElseGet(() -> {
                long now = clock.millis();
                return new SaveMetadata(SCHEMA_VERSION, modVersion, now, now);
            });
            if (metadata.schemaVersion() != SCHEMA_VERSION) {
                throw new IllegalStateException(
                        "Unsupported save schema version: " + metadata.schemaVersion());
            }
            writeMetadata(metadata);
        } catch (IOException exception) {
            throw new IllegalStateException(
                    "Failed to initialize persistence at " + rootDirectory, exception);
        }
    }

    private Optional<SaveMetadata> loadMetadata() throws IOException {
        Path path = rootDirectory.resolve(METADATA_FILE);
        if (!Files.exists(path)) {
            return Optional.empty();
        }
        Properties properties = new Properties();
        try (InputStream input = Files.newInputStream(path)) {
            properties.load(input);
        }
        return Optional.of(new SaveMetadata(
                requiredInt(properties, "schemaVersion"),
                required(properties, "modVersion"),
                requiredLong(properties, "createdAt"),
                requiredLong(properties, "updatedAt")));
    }

    private void updateMetadata() {
        metadata = new SaveMetadata(
                metadata.schemaVersion(),
                modVersion,
                metadata.createdAt(),
                clock.millis());
        try {
            writeMetadata(metadata);
        } catch (IOException exception) {
            throw new IllegalStateException("Failed to update save metadata", exception);
        }
    }

    private void writeMetadata(SaveMetadata value) throws IOException {
        Properties properties = new Properties();
        properties.setProperty("schemaVersion", Integer.toString(value.schemaVersion()));
        properties.setProperty("modVersion", value.modVersion());
        properties.setProperty("createdAt", Long.toString(value.createdAt()));
        properties.setProperty("updatedAt", Long.toString(value.updatedAt()));
        Path target = rootDirectory.resolve(METADATA_FILE);
        Path temporary = target.resolveSibling(target.getFileName() + ".tmp");
        try (OutputStream output = Files.newOutputStream(temporary)) {
            properties.store(output, "Living World save metadata");
        }
        moveAtomically(temporary, target);
    }

    private Properties encodeRegion(Region region) {
        Properties properties = new Properties();
        properties.setProperty("schemaVersion", Integer.toString(SCHEMA_VERSION));
        properties.setProperty("id", region.getId().toString());
        properties.setProperty("dimensionId", region.getCoordinate().dimensionId());
        properties.setProperty("regionX", Integer.toString(region.getCoordinate().x()));
        properties.setProperty("regionZ", Integer.toString(region.getCoordinate().z()));
        properties.setProperty("lifecycleState", region.getLifecycleState().name());
        properties.setProperty(
                "createdAtSimulationTick",
                Long.toString(region.getCreatedAtSimulationTick()));
        properties.setProperty(
                "lastUpdatedSimulationTick",
                Long.toString(region.getLastUpdatedSimulationTick()));

        RegionFlags flags = region.getFlags();
        properties.setProperty("flag.playerActivity", Boolean.toString(flags.isHasPlayerActivity()));
        properties.setProperty("flag.highPollution", Boolean.toString(flags.isHasHighPollution()));
        properties.setProperty("flag.lowSoilQuality", Boolean.toString(flags.isHasLowSoilQuality()));
        properties.setProperty(
                "flag.activeEcosystemEvent",
                Boolean.toString(flags.isHasActiveEcosystemEvent()));
        properties.setProperty(
                "flag.forceLoaded",
                Boolean.toString(flags.isForceLoadedBySimulation()));
        properties.setProperty("flag.corrupted", Boolean.toString(flags.isCorrupted()));

        RegionMetrics metrics = region.getMetrics();
        properties.setProperty(
                "metric.ecosystemHealth",
                Double.toString(metrics.getEcosystemHealth()));
        properties.setProperty(
                "metric.pollutionScore",
                Double.toString(metrics.getPollutionScore()));
        properties.setProperty("metric.soilQuality", Double.toString(metrics.getSoilQuality()));
        properties.setProperty("metric.waterQuality", Double.toString(metrics.getWaterQuality()));
        properties.setProperty(
                "metric.vegetationPressure",
                Double.toString(metrics.getVegetationPressure()));
        properties.setProperty(
                "metric.resourceDepletion",
                Double.toString(metrics.getResourceDepletion()));
        properties.setProperty(
                "metric.recoveryPressure",
                Double.toString(metrics.getRecoveryPressure()));
        return properties;
    }

    private Region decodeRegion(Properties properties) {
        int schemaVersion = requiredInt(properties, "schemaVersion");
        if (schemaVersion != SCHEMA_VERSION) {
            throw new IllegalStateException(
                    "Unsupported region schema version: " + schemaVersion);
        }

        RegionFlags flags = new RegionFlags();
        flags.setHasPlayerActivity(requiredBoolean(properties, "flag.playerActivity"));
        flags.setHasHighPollution(requiredBoolean(properties, "flag.highPollution"));
        flags.setHasLowSoilQuality(requiredBoolean(properties, "flag.lowSoilQuality"));
        flags.setHasActiveEcosystemEvent(
                requiredBoolean(properties, "flag.activeEcosystemEvent"));
        flags.setForceLoadedBySimulation(requiredBoolean(properties, "flag.forceLoaded"));
        flags.setCorrupted(requiredBoolean(properties, "flag.corrupted"));

        RegionMetrics metrics = new RegionMetrics();
        metrics.setEcosystemHealth(requiredDouble(properties, "metric.ecosystemHealth"));
        metrics.setPollutionScore(requiredDouble(properties, "metric.pollutionScore"));
        metrics.setSoilQuality(requiredDouble(properties, "metric.soilQuality"));
        metrics.setWaterQuality(requiredDouble(properties, "metric.waterQuality"));
        metrics.setVegetationPressure(requiredDouble(properties, "metric.vegetationPressure"));
        metrics.setResourceDepletion(requiredDouble(properties, "metric.resourceDepletion"));
        metrics.setRecoveryPressure(requiredDouble(properties, "metric.recoveryPressure"));

        Region region = new Region(
                UUID.fromString(required(properties, "id")),
                new RegionCoordinate(
                        required(properties, "dimensionId"),
                        requiredInt(properties, "regionX"),
                        requiredInt(properties, "regionZ")),
                RegionLifecycleState.valueOf(required(properties, "lifecycleState")),
                requiredLong(properties, "createdAtSimulationTick"),
                requiredLong(properties, "lastUpdatedSimulationTick"),
                false,
                flags,
                metrics,
                new RegionModuleData());
        region.validate();
        return region;
    }

    private Path regionPath(RegionCoordinate coordinate) {
        String dimension = Base64.getUrlEncoder()
                .withoutPadding()
                .encodeToString(coordinate.dimensionId().getBytes(StandardCharsets.UTF_8));
        return regionsDirectory.resolve(
                dimension + "_" + coordinate.x() + "_" + coordinate.z() + ".properties");
    }

    private void quarantineCorruptFile(Path file, Exception exception) {
        Path quarantined = file.resolveSibling(
                file.getFileName() + ".corrupt-" + clock.millis());
        try {
            Files.move(file, quarantined, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException moveException) {
            exception.addSuppressed(moveException);
        }
        LivingWorldLogger.warn(
                DiagnosticCategory.PERSISTENCE,
                "Quarantined unreadable region file " + file + ": " + exception.getMessage());
    }

    private static String required(Properties properties, String key) {
        String value = properties.getProperty(key);
        if (value == null || value.isBlank()) {
            throw new IllegalStateException("Missing required property: " + key);
        }
        return value;
    }

    private static int requiredInt(Properties properties, String key) {
        return Integer.parseInt(required(properties, key));
    }

    private static long requiredLong(Properties properties, String key) {
        return Long.parseLong(required(properties, key));
    }

    private static double requiredDouble(Properties properties, String key) {
        return Double.parseDouble(required(properties, key));
    }

    private static boolean requiredBoolean(Properties properties, String key) {
        String value = required(properties, key);
        if (!value.equals("true") && !value.equals("false")) {
            throw new IllegalStateException("Invalid boolean property " + key + ": " + value);
        }
        return Boolean.parseBoolean(value);
    }

    private static void requireRegion(Region region) {
        if (region == null) {
            throw new IllegalArgumentException("region must not be null");
        }
    }

    private static void moveAtomically(Path source, Path target) throws IOException {
        try {
            Files.move(
                    source,
                    target,
                    StandardCopyOption.ATOMIC_MOVE,
                    StandardCopyOption.REPLACE_EXISTING);
        } catch (AtomicMoveNotSupportedException exception) {
            Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
        }
    }

    private static void tryDelete(Path path) {
        try {
            Files.deleteIfExists(path);
        } catch (IOException ignored) {
            // The target save already succeeded or reported its own failure.
        }
    }
}
