package de.effigenix.domain.inventory;

import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;

import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Stock aggregate root.
 *
 * Invariants:
 * - ArticleId + StorageLocationId = unique (Application Layer, Repository-Concern)
 * - MinimumLevel optional (quantity amount >= 0)
 * - MinimumShelfLife optional (days > 0)
 * - BatchReference (batchId + batchType) unique within batches
 * - blockBatch: AVAILABLE/EXPIRING_SOON → BLOCKED; EXPIRED → not allowed; already BLOCKED → error
 * - unblockBatch: BLOCKED → AVAILABLE or EXPIRING_SOON (based on MHD check); not BLOCKED → error
 * - markExpiredBatches: AVAILABLE/EXPIRING_SOON with expiryDate < today → EXPIRED; BLOCKED untouched
 * - markExpiringSoonBatches: AVAILABLE with expiryDate < today+minimumShelfLife and not already expired → EXPIRING_SOON; requires minimumShelfLife
 * - availableQuantity: sum of AVAILABLE + EXPIRING_SOON batch quantities minus all reservation allocations
 * - isBelowMinimumLevel: true when minimumLevel is set and availableQuantity < minimumLevel amount
 * - reserve: FEFO allocation across AVAILABLE/EXPIRING_SOON batches sorted by expiryDate ASC
 * - releaseReservation: removes reservation by ID, implicitly freeing allocated quantities
 * - confirmReservation: deducts allocated quantities from batches, removes reservation, returns ConfirmedReservation
 *   - batches with quantity 0 after deduction are kept (FK from stock_movements requires batch persistence)
 * - reservations track allocated quantities per batch; no over-reservation possible
 */
public class Stock {

    private static final System.Logger logger = System.getLogger(Stock.class.getName());

    private final StockId id;
    private final ArticleId articleId;
    private final StorageLocationId storageLocationId;
    private MinimumLevel minimumLevel;
    private MinimumShelfLife minimumShelfLife;
    private final List<StockBatch> batches;
    private final List<Reservation> reservations;

    private Stock(
            StockId id,
            ArticleId articleId,
            StorageLocationId storageLocationId,
            MinimumLevel minimumLevel,
            MinimumShelfLife minimumShelfLife,
            List<StockBatch> batches,
            List<Reservation> reservations
    ) {
        this.id = id;
        this.articleId = articleId;
        this.storageLocationId = storageLocationId;
        this.minimumLevel = minimumLevel;
        this.minimumShelfLife = minimumShelfLife;
        this.batches = new ArrayList<>(batches);
        this.reservations = new ArrayList<>(reservations);
    }

    /**
     * Factory: Erzeugt ein neues Stock-Aggregat aus rohen Eingaben.
     * Orchestriert Validierung aller VOs intern.
     */
    public static Result<StockError, Stock> create(StockDraft draft) {
        // 1. ArticleId validieren (Pflicht)
        if (draft.articleId() == null || draft.articleId().isBlank()) {
            return Result.failure(new StockError.InvalidArticleId("must not be blank"));
        }
        ArticleId articleId;
        try {
            articleId = ArticleId.of(draft.articleId());
        } catch (IllegalArgumentException e) {
            return Result.failure(new StockError.InvalidArticleId(e.getMessage()));
        }

        // 2. StorageLocationId validieren (Pflicht)
        if (draft.storageLocationId() == null || draft.storageLocationId().isBlank()) {
            return Result.failure(new StockError.InvalidStorageLocationId("must not be blank"));
        }
        StorageLocationId storageLocationId;
        try {
            storageLocationId = StorageLocationId.of(draft.storageLocationId());
        } catch (IllegalArgumentException e) {
            return Result.failure(new StockError.InvalidStorageLocationId(e.getMessage()));
        }

        // 3. MinimumLevel optional
        MinimumLevel minimumLevel = null;
        if (draft.minimumLevelAmount() != null || draft.minimumLevelUnit() != null) {
            switch (MinimumLevel.of(draft.minimumLevelAmount(), draft.minimumLevelUnit())) {
                case Result.Failure(var err) -> { return Result.failure(err); }
                case Result.Success(var val) -> minimumLevel = val;
            }
        }

        // 4. MinimumShelfLife optional
        MinimumShelfLife minimumShelfLife = null;
        if (draft.minimumShelfLifeDays() != null) {
            switch (MinimumShelfLife.of(draft.minimumShelfLifeDays())) {
                case Result.Failure(var err) -> { return Result.failure(err); }
                case Result.Success(var val) -> minimumShelfLife = val;
            }
        }

        return Result.success(new Stock(
                StockId.generate(), articleId, storageLocationId, minimumLevel, minimumShelfLife, List.of(), List.of()
        ));
    }

    /**
     * Reconstitute from persistence without re-validation.
     */
    public static Stock reconstitute(
            StockId id,
            ArticleId articleId,
            StorageLocationId storageLocationId,
            MinimumLevel minimumLevel,
            MinimumShelfLife minimumShelfLife,
            List<StockBatch> batches,
            List<Reservation> reservations
    ) {
        return new Stock(id, articleId, storageLocationId, minimumLevel, minimumShelfLife, batches, reservations);
    }

    // ==================== Update ====================

    /**
     * Aktualisiert die Bestandsparameter (MinimumLevel, MinimumShelfLife).
     * Null-Felder im Draft werden ignoriert (kein Update).
     * Validierung identisch zu create().
     */
    public Result<StockError, Void> update(StockUpdateDraft draft) {
        // 1. MinimumLevel optional aktualisieren
        if (draft.minimumLevelAmount() != null || draft.minimumLevelUnit() != null) {
            switch (MinimumLevel.of(draft.minimumLevelAmount(), draft.minimumLevelUnit())) {
                case Result.Failure(var err) -> { return Result.failure(err); }
                case Result.Success(var val) -> this.minimumLevel = val;
            }
        }

        // 2. MinimumShelfLife optional aktualisieren
        if (draft.minimumShelfLifeDays() != null) {
            switch (MinimumShelfLife.of(draft.minimumShelfLifeDays())) {
                case Result.Failure(var err) -> { return Result.failure(err); }
                case Result.Success(var val) -> this.minimumShelfLife = val;
            }
        }

        return Result.success(null);
    }

    // ==================== Batch Management ====================

    public Result<StockError, StockBatch> addBatch(StockBatchDraft draft) {
        StockBatch batch;
        switch (StockBatch.create(draft)) {
            case Result.Failure(var err) -> { return Result.failure(err); }
            case Result.Success(var val) -> batch = val;
        }

        if (hasBatchReference(batch.batchReference())) {
            return Result.failure(new StockError.DuplicateBatchReference(
                    batch.batchReference().batchId(), batch.batchReference().batchType().name()));
        }

        this.batches.add(batch);
        return Result.success(batch);
    }

    public Result<StockError, Void> removeBatch(StockBatchId batchId, Quantity quantity) {
        StockBatch batch = batches.stream()
                .filter(b -> b.id().equals(batchId))
                .findFirst()
                .orElse(null);
        if (batch == null) {
            return Result.failure(new StockError.BatchNotFound(batchId.value()));
        }

        Quantity remaining;
        switch (batch.removeQuantity(quantity)) {
            case Result.Failure(var err) -> { return Result.failure(err); }
            case Result.Success(var val) -> remaining = val;
        }

        if (remaining.amount().signum() == 0) {
            this.batches.remove(batch);
        } else {
            int index = this.batches.indexOf(batch);
            this.batches.set(index, batch.withQuantity(remaining));
        }

        return Result.success(null);
    }

    public Result<StockError, Void> blockBatch(StockBatchId batchId) {
        StockBatch batch = batches.stream()
                .filter(b -> b.id().equals(batchId))
                .findFirst()
                .orElse(null);
        if (batch == null) {
            return Result.failure(new StockError.BatchNotFound(batchId.value()));
        }
        if (batch.status() == StockBatchStatus.BLOCKED) {
            return Result.failure(new StockError.BatchAlreadyBlocked(batchId.value()));
        }
        if (batch.status() == StockBatchStatus.EXPIRED) {
            return Result.failure(new StockError.BatchNotAvailable(batchId.value(), batch.status().name()));
        }

        int index = this.batches.indexOf(batch);
        this.batches.set(index, batch.withStatus(StockBatchStatus.BLOCKED));
        return Result.success(null);
    }

    public Result<StockError, Void> unblockBatch(StockBatchId batchId, LocalDate referenceDate) {
        StockBatch batch = batches.stream()
                .filter(b -> b.id().equals(batchId))
                .findFirst()
                .orElse(null);
        if (batch == null) {
            return Result.failure(new StockError.BatchNotFound(batchId.value()));
        }
        if (batch.status() != StockBatchStatus.BLOCKED) {
            return Result.failure(new StockError.BatchNotBlocked(batchId.value()));
        }

        StockBatchStatus newStatus = StockBatchStatus.AVAILABLE;
        if (minimumShelfLife != null && batch.expiryDate() != null) {
            LocalDate threshold = referenceDate.plusDays(minimumShelfLife.days());
            if (batch.expiryDate().isBefore(threshold)) {
                newStatus = StockBatchStatus.EXPIRING_SOON;
            }
        }

        int index = this.batches.indexOf(batch);
        this.batches.set(index, batch.withStatus(newStatus));
        return Result.success(null);
    }

    // ==================== Reservation (FEFO) ====================

    public Result<StockError, Reservation> reserve(ReservationDraft draft) {
        // 1. ReferenceType validieren
        if (draft.referenceType() == null || draft.referenceType().isBlank()) {
            return Result.failure(new StockError.InvalidReferenceType("must not be blank"));
        }
        ReferenceType referenceType;
        try {
            referenceType = ReferenceType.valueOf(draft.referenceType());
        } catch (IllegalArgumentException e) {
            return Result.failure(new StockError.InvalidReferenceType(draft.referenceType()));
        }

        // 2. ReferenceId validieren
        if (draft.referenceId() == null || draft.referenceId().isBlank()) {
            return Result.failure(new StockError.InvalidReferenceId("must not be blank"));
        }

        // 3. Quantity validieren
        Quantity requestedQuantity;
        try {
            BigDecimal amount = new BigDecimal(draft.quantityAmount());
            UnitOfMeasure uom;
            try {
                uom = UnitOfMeasure.valueOf(draft.quantityUnit());
            } catch (IllegalArgumentException | NullPointerException e) {
                return Result.failure(new StockError.InvalidQuantity("Invalid unit: " + draft.quantityUnit()));
            }
            switch (Quantity.of(amount, uom)) {
                case Result.Failure(var err) -> {
                    return Result.failure(new StockError.InvalidQuantity(err.message()));
                }
                case Result.Success(var val) -> requestedQuantity = val;
            }
        } catch (NumberFormatException | NullPointerException e) {
            return Result.failure(new StockError.InvalidQuantity("Invalid quantity amount: " + draft.quantityAmount()));
        }

        // 4. Priority validieren
        if (draft.priority() == null || draft.priority().isBlank()) {
            return Result.failure(new StockError.InvalidReservationPriority("must not be blank"));
        }
        ReservationPriority priority;
        try {
            priority = ReservationPriority.valueOf(draft.priority());
        } catch (IllegalArgumentException e) {
            return Result.failure(new StockError.InvalidReservationPriority(draft.priority()));
        }

        // 5. Verfügbare Chargen ermitteln (AVAILABLE + EXPIRING_SOON), sortiert nach expiryDate ASC (FEFO)
        List<StockBatch> availableBatches = batches.stream()
                .filter(StockBatch::isRemovable)
                .sorted(Comparator.comparing(StockBatch::expiryDate, Comparator.nullsLast(Comparator.naturalOrder())))
                .toList();

        // 6. Bereits allokierte Mengen pro Batch berechnen
        Map<StockBatchId, BigDecimal> allocatedPerBatch = new HashMap<>();
        for (Reservation r : reservations) {
            for (StockBatchAllocation alloc : r.allocations()) {
                allocatedPerBatch.merge(alloc.stockBatchId(), alloc.allocatedQuantity().amount(), BigDecimal::add);
            }
        }

        // 7. Verfügbare Menge prüfen
        BigDecimal totalAvailable = BigDecimal.ZERO;
        for (StockBatch batch : availableBatches) {
            BigDecimal alreadyAllocated = allocatedPerBatch.getOrDefault(batch.id(), BigDecimal.ZERO);
            BigDecimal free = batch.quantity().amount().subtract(alreadyAllocated);
            if (free.compareTo(BigDecimal.ZERO) > 0) {
                totalAvailable = totalAvailable.add(free);
            }
        }

        if (totalAvailable.compareTo(requestedQuantity.amount()) < 0) {
            return Result.failure(new StockError.InsufficientStock(
                    totalAvailable.toPlainString(), requestedQuantity.amount().toPlainString()));
        }

        // 8. FEFO-Allokation
        List<StockBatchAllocation> allocations = new ArrayList<>();
        BigDecimal remaining = requestedQuantity.amount();
        for (StockBatch batch : availableBatches) {
            if (remaining.compareTo(BigDecimal.ZERO) <= 0) break;

            BigDecimal alreadyAllocated = allocatedPerBatch.getOrDefault(batch.id(), BigDecimal.ZERO);
            BigDecimal free = batch.quantity().amount().subtract(alreadyAllocated);
            if (free.compareTo(BigDecimal.ZERO) <= 0) continue;

            BigDecimal toAllocate = remaining.min(free);
            allocations.add(new StockBatchAllocation(
                    AllocationId.generate(),
                    batch.id(),
                    Quantity.reconstitute(toAllocate, requestedQuantity.uom())
            ));
            remaining = remaining.subtract(toAllocate);
        }

        // 9. Reservation erstellen
        var reservation = new Reservation(
                ReservationId.generate(), referenceType, draft.referenceId(),
                requestedQuantity, priority, Instant.now(), allocations
        );
        this.reservations.add(reservation);

        return Result.success(reservation);
    }

    public Result<StockError, ConfirmedReservation> confirmReservation(ReservationId reservationId) {
        // 1. Reservation finden
        Reservation reservation = reservations.stream()
                .filter(r -> r.id().equals(reservationId))
                .findFirst()
                .orElse(null);
        if (reservation == null) {
            return Result.failure(new StockError.ReservationNotFound(reservationId.value()));
        }

        // 2. Für jede Allocation: Batch finden, Menge abziehen, ConfirmedAllocation erzeugen
        List<ConfirmedAllocation> confirmedAllocations = new ArrayList<>();
        for (StockBatchAllocation alloc : reservation.allocations()) {
            StockBatch batch = batches.stream()
                    .filter(b -> b.id().equals(alloc.stockBatchId()))
                    .findFirst()
                    .orElse(null);
            if (batch == null) {
                return Result.failure(new StockError.BatchNotFound(alloc.stockBatchId().value()));
            }

            confirmedAllocations.add(new ConfirmedAllocation(
                    batch.id(), batch.batchReference(), alloc.allocatedQuantity()));

            Quantity remaining;
            switch (batch.removeQuantity(alloc.allocatedQuantity())) {
                case Result.Failure(var err) -> { return Result.failure(err); }
                case Result.Success(var val) -> remaining = val;
            }

            int index = this.batches.indexOf(batch);
            this.batches.set(index, batch.withQuantity(remaining));
        }

        // 3. Reservation entfernen
        this.reservations.remove(reservation);

        return Result.success(new ConfirmedReservation(
                reservation.referenceType(), reservation.referenceId(), confirmedAllocations));
    }

    public Result<StockError, Void> releaseReservation(ReservationId reservationId) {
        Reservation reservation = reservations.stream()
                .filter(r -> r.id().equals(reservationId))
                .findFirst()
                .orElse(null);
        if (reservation == null) {
            return Result.failure(new StockError.ReservationNotFound(reservationId.value()));
        }
        this.reservations.remove(reservation);
        return Result.success(null);
    }

    // ==================== Expiry Management ====================

    public Result<StockError, List<StockBatchId>> markExpiredBatches(LocalDate today) {
        List<StockBatchId> marked = new ArrayList<>();
        for (int i = 0; i < batches.size(); i++) {
            StockBatch batch = batches.get(i);
            if ((batch.status() == StockBatchStatus.AVAILABLE || batch.status() == StockBatchStatus.EXPIRING_SOON)
                    && batch.expiryDate() != null
                    && batch.expiryDate().isBefore(today)) {
                batches.set(i, batch.withStatus(StockBatchStatus.EXPIRED));
                marked.add(batch.id());
            }
        }
        return Result.success(marked);
    }

    public Result<StockError, List<StockBatchId>> markExpiringSoonBatches(LocalDate today) {
        if (minimumShelfLife == null) {
            return Result.success(List.of());
        }
        List<StockBatchId> marked = new ArrayList<>();
        LocalDate threshold = today.plusDays(minimumShelfLife.days());
        for (int i = 0; i < batches.size(); i++) {
            StockBatch batch = batches.get(i);
            if (batch.status() == StockBatchStatus.AVAILABLE
                    && batch.expiryDate() != null
                    && batch.expiryDate().isBefore(threshold)
                    && !batch.expiryDate().isBefore(today)) {
                batches.set(i, batch.withStatus(StockBatchStatus.EXPIRING_SOON));
                marked.add(batch.id());
            }
        }
        return Result.success(marked);
    }

    // ==================== Queries ====================

    /**
     * Returns the uniform UoM if all batches share the same unit, empty otherwise.
     * Empty batches list also returns empty.
     */
    public Optional<UnitOfMeasure> uniformUnitOfMeasure() {
        Set<UnitOfMeasure> units = batches.stream()
                .map(b -> b.quantity().uom())
                .collect(Collectors.toSet());
        return units.size() == 1 ? Optional.of(units.iterator().next()) : Optional.empty();
    }

    public BigDecimal availableQuantity() {
        BigDecimal gross = batches.stream()
                .filter(b -> b.status() == StockBatchStatus.AVAILABLE || b.status() == StockBatchStatus.EXPIRING_SOON)
                .map(b -> b.quantity().amount())
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        BigDecimal totalAllocated = reservations.stream()
                .flatMap(r -> r.allocations().stream())
                .map(a -> a.allocatedQuantity().amount())
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        BigDecimal net = gross.subtract(totalAllocated);
        return net.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : net;
    }

    public boolean isBelowMinimumLevel() {
        if (minimumLevel == null) {
            return false;
        }
        UnitOfMeasure minimumUnit = minimumLevel.quantity().uom();
        Optional<UnitOfMeasure> uniform = uniformUnitOfMeasure();
        if (uniform.isEmpty() && !batches.isEmpty()) {
            logger.log(System.Logger.Level.WARNING,
                    "Mixed UoMs in stock {0} — skipping below-minimum check", id);
            return false;
        }
        if (uniform.isPresent() && uniform.get() != minimumUnit) {
            logger.log(System.Logger.Level.WARNING,
                    "Unit mismatch in stock {0}: batch unit {1} vs minimum level unit {2} — skipping below-minimum check",
                    id, uniform.get(), minimumUnit);
            return false;
        }
        return availableQuantity().compareTo(minimumLevel.quantity().amount()) < 0;
    }

    // ==================== Getters ====================

    public StockId id() { return id; }
    public ArticleId articleId() { return articleId; }
    public StorageLocationId storageLocationId() { return storageLocationId; }
    public MinimumLevel minimumLevel() { return minimumLevel; }
    public MinimumShelfLife minimumShelfLife() { return minimumShelfLife; }
    public List<StockBatch> batches() { return Collections.unmodifiableList(batches); }
    public List<Reservation> reservations() { return Collections.unmodifiableList(reservations); }

    // ==================== Helpers ====================

    private boolean hasBatchReference(BatchReference ref) {
        return batches.stream().anyMatch(b -> b.batchReference().equals(ref));
    }

    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof Stock other)) return false;
        return Objects.equals(id, other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return "Stock{id=" + id + ", articleId=" + articleId + ", storageLocationId=" + storageLocationId + "}";
    }
}
