package at.procon.eventhub.tachographfilesession.service;

import at.procon.eventhub.tachographfilesession.model.DriverExtractionSession;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardActivityInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedCardVehicleUsageInterval;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriver;
import at.procon.eventhub.tachographfilesession.model.ExtractedDriverCard;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicle;
import at.procon.eventhub.tachographfilesession.model.ExtractedVehicleRegistration;
import at.procon.eventhub.tachographfilesession.model.ExtractionStats;
import at.procon.eventhub.tachographfilesession.model.ExtractionWarning;
import at.procon.eventhub.tachographfilesession.model.TachographFileSession;
import at.procon.eventhub.tachographfilesession.model.TachographFileSessionMetadata;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.UUID;
import org.springframework.stereotype.Component;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

@Component
public class DriverCardXmlExtractionService {

    private final DriverKeyFactory driverKeyFactory;
    private final VehicleKeyFactory vehicleKeyFactory;

    public DriverCardXmlExtractionService(DriverKeyFactory driverKeyFactory, VehicleKeyFactory vehicleKeyFactory) {
        this.driverKeyFactory = driverKeyFactory;
        this.vehicleKeyFactory = vehicleKeyFactory;
    }

    public TachographFileSession extract(
            TachographXmlParser.ParsedTachographXml parsedXml,
            TachographFileSessionMetadata metadata,
            Instant createdAt,
            Instant expiresAt
    ) {
        Document document = parsedXml.document();
        List<ExtractionWarning> warnings = new ArrayList<>();

        ExtractedDriverCard driverCard = extractDriverCard(document, warnings);
        if (driverCard == null || driverCard.cardNumber() == null) {
            throw new TachographXmlValidationException("Driver card identification could not be extracted from XML.");
        }
        String driverKey = driverKeyFactory.createDriverKey(driverCard.cardNation(), driverCard.cardNumber());
        driverCard = new ExtractedDriverCard(
                driverKeyFactory.createSourceDriverCardId(driverKey),
                driverCard.cardNation(),
                driverCard.cardNumber(),
                driverCard.issuingAuthorityName(),
                driverCard.issueDate(),
                driverCard.validityBegin(),
                driverCard.expiryDate()
        );
        ExtractedDriver driver = extractDriver(document, driverKey, warnings);

        LinkedHashMap<String, ExtractedVehicleRegistration> registrationsByKey = new LinkedHashMap<>();
        LinkedHashMap<String, ExtractedVehicle> vehiclesByKey = new LinkedHashMap<>();
        List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals =
                extractVehicleUsageIntervals(document, registrationsByKey, vehiclesByKey, warnings);
        List<ExtractedCardActivityInterval> activityIntervals =
                assignVehicleCoverage(extractActivityIntervals(document, warnings), vehicleUsageIntervals);

        DriverExtractionSession driverSession = new DriverExtractionSession(
                driverKey,
                driver,
                driverCard,
                List.copyOf(registrationsByKey.values()),
                List.copyOf(vehiclesByKey.values()),
                List.copyOf(vehicleUsageIntervals),
                List.copyOf(activityIntervals),
                List.of(),
                List.copyOf(warnings)
        );
        Map<String, DriverExtractionSession> driversByKey = Map.of(driverKey, driverSession);
        ExtractionStats stats = new ExtractionStats(
                1,
                activityIntervals.size(),
                vehicleUsageIntervals.size(),
                registrationsByKey.size(),
                vehiclesByKey.size(),
                warnings.size()
        );
        return new TachographFileSession(
                UUID.randomUUID(),
                metadata,
                driversByKey,
                stats,
                List.copyOf(warnings),
                createdAt,
                expiresAt
        );
    }

    private ExtractedDriverCard extractDriverCard(Document document, List<ExtractionWarning> warnings) {
        Element identification = child(document.getDocumentElement(), "Identification");
        if (identification == null) {
            warnings.add(new ExtractionWarning("MISSING_IDENTIFICATION", "Driver card identification block is missing.", "/DriverCard"));
            return null;
        }
        Element cardIdentification = child(identification, "cardIdentification");
        String cardNation = childText(cardIdentification, "cardIssuingMemberState");
        String cardNumber = joinCardNumber(identification);
        String authority = childText(child(cardIdentification, "cardIssuingAuthorityName"), "name");
        return new ExtractedDriverCard(
                null,
                cardNation,
                cardNumber,
                authority,
                offsetDateTime(childText(cardIdentification, "cardIssueDate")),
                offsetDateTime(childText(cardIdentification, "cardValidityBegin")),
                offsetDateTime(childText(cardIdentification, "cardExpiryDate"))
        );
    }

    private ExtractedDriver extractDriver(Document document, String driverKey, List<ExtractionWarning> warnings) {
        Element identification = child(document.getDocumentElement(), "Identification");
        if (identification == null) {
            warnings.add(new ExtractionWarning("MISSING_DRIVER", "Driver holder identification block is missing.", "/DriverCard"));
            return new ExtractedDriver(
                    driverKey,
                    driverKeyFactory.createSourceDriverId(driverKey),
                    null,
                    null,
                    null,
                    null,
                    null,
                    null,
                    null
            );
        }
        Element driverCardHolderIdentification = child(identification, "driverCardHolderIdentification");
        Element cardHolderName = child(driverCardHolderIdentification, "cardHolderName");
        Element licenceInfo = child(document.getDocumentElement(), "DrivingLicenseInfo");
        Element cardDrivingLicenseInformation = child(licenceInfo, "cardDrivingLicenseInformation");
        return new ExtractedDriver(
                driverKey,
                driverKeyFactory.createSourceDriverId(driverKey),
                childText(child(cardHolderName, "holderSurname"), "name"),
                childText(child(cardHolderName, "holderFirstNames"), "name"),
                localDate(child(driverCardHolderIdentification, "cardHolderBirthDate")),
                childText(driverCardHolderIdentification, "cardHolderPreferredLanguage"),
                childText(cardDrivingLicenseInformation, "drivingLicenceNumber"),
                childText(cardDrivingLicenseInformation, "drivingLicenceIssuingNation"),
                childText(child(cardDrivingLicenseInformation, "drivingLicenceIssuingAuthority"), "name")
        );
    }

    private List<ExtractedCardVehicleUsageInterval> extractVehicleUsageIntervals(
            Document document,
            Map<String, ExtractedVehicleRegistration> registrationsByKey,
            Map<String, ExtractedVehicle> vehiclesByKey,
            List<ExtractionWarning> warnings
    ) {
        Element cardVehiclesUsed = child(child(document.getDocumentElement(), "VehiclesUsed"), "cardVehiclesUsed");
        List<Element> records = children(cardVehiclesUsed, "cardVehicleRecords");
        List<ExtractedCardVehicleUsageInterval> intervals = new ArrayList<>(records.size());
        for (int i = 0; i < records.size(); i++) {
            Element record = records.get(i);
            String path = "/DriverCard/VehiclesUsed/cardVehiclesUsed/cardVehicleRecords[" + (i + 1) + "]";
            OffsetDateTime from = offsetDateTime(childText(record, "vehicleFirstUse"));
            OffsetDateTime to = offsetDateTime(childText(record, "vehicleLastUse"));
            if (from == null) {
                warnings.add(new ExtractionWarning("MISSING_VEHICLE_FIRST_USE", "Driver-card vehicle record is missing vehicleFirstUse.", path));
                continue;
            }
            if (to != null && to.isBefore(from)) {
                warnings.add(new ExtractionWarning("INVALID_VEHICLE_USE_INTERVAL", "Driver-card vehicle record has an invalid first/last use range.", path));
                continue;
            }
            Element vehicleRegistration = child(record, "vehicleRegistration");
            String registrationNation = childText(vehicleRegistration, "vehicleRegistrationNation");
            String registrationNumber = childText(child(vehicleRegistration, "vehicleRegistrationNumber"), "vehicleRegNumber");
            String registrationKey = vehicleKeyFactory.createRegistrationKey(registrationNation, registrationNumber);
            registrationsByKey.putIfAbsent(
                    registrationKey,
                    new ExtractedVehicleRegistration(
                            registrationKey,
                            vehicleKeyFactory.createSourceVehicleRegistrationId(registrationKey),
                            registrationNation,
                            registrationNumber
                    )
            );
            String vin = childText(record, "vehicleIdentificationNumber");
            String vehicleKey = vehicleKeyFactory.createVehicleKey(vin);
            if (vehicleKey != null) {
                vehiclesByKey.putIfAbsent(
                        vehicleKey,
                        new ExtractedVehicle(vehicleKey, vehicleKeyFactory.createSourceVehicleId(vehicleKey), vin)
                );
            }
            if (to == null) {
                warnings.add(new ExtractionWarning("OPEN_VEHICLE_USAGE", "Driver-card vehicle record has no vehicleLastUse and was kept open-ended.", path));
            }
            intervals.add(new ExtractedCardVehicleUsageInterval(
                    "CVU-" + (i + 1),
                    from,
                    to,
                    longValue(childText(record, "vehicleOdometerBegin")),
                    longValue(childText(record, "vehicleOdometerEnd")),
                    registrationKey,
                    vehicleKey,
                    path
            ));
        }
        intervals.sort(Comparator.comparing(ExtractedCardVehicleUsageInterval::from));
        return intervals;
    }

    private List<ExtractedCardActivityInterval> extractActivityIntervals(Document document, List<ExtractionWarning> warnings) {
        Element cardDriverActivity = child(child(document.getDocumentElement(), "DriverActivityData"), "cardDriverActivity");
        List<Element> dayRecords = children(cardDriverActivity, "cardActivityDailyRecord");
        List<ExtractedCardActivityInterval> intervals = new ArrayList<>(dayRecords.size() * 8);
        int intervalNo = 0;
        for (int dayIndex = 0; dayIndex < dayRecords.size(); dayIndex++) {
            Element dayRecord = dayRecords.get(dayIndex);
            String dayPath = "/DriverCard/DriverActivityData/cardDriverActivity/cardActivityDailyRecord[" + (dayIndex + 1) + "]";
            OffsetDateTime recordDate = offsetDateTime(childText(dayRecord, "activityRecordDate"));
            if (recordDate == null) {
                warnings.add(new ExtractionWarning("MISSING_ACTIVITY_RECORD_DATE", "Activity daily record has no activityRecordDate.", dayPath));
                continue;
            }
            LocalDate date = recordDate.withOffsetSameInstant(ZoneOffset.UTC).toLocalDate();
            List<Element> changes = children(dayRecord, "activityChangeInfos");
            List<ActivityChange> parsedChanges = new ArrayList<>(changes.size());
            for (int changeIndex = 0; changeIndex < changes.size(); changeIndex++) {
                Element change = changes.get(changeIndex);
                OffsetDateTime from = combine(date, childText(change, "timeOfChange"));
                if (from == null) {
                    warnings.add(new ExtractionWarning("INVALID_ACTIVITY_CHANGE_TIME", "Activity change has invalid timeOfChange.", dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"));
                    continue;
                }
                parsedChanges.add(new ActivityChange(
                        from,
                        normalizeActivity(childText(change, "activity")),
                        normalizeToken(childText(change, "slot")),
                        normalizeToken(childText(change, "cardStatus")),
                        normalizeToken(childText(change, "drivingStatus")),
                        dayPath + "/activityChangeInfos[" + (changeIndex + 1) + "]"
                ));
            }
            parsedChanges.sort(Comparator.comparing(ActivityChange::from));
            for (int i = 0; i + 1 < parsedChanges.size(); i++) {
                ActivityChange current = parsedChanges.get(i);
                OffsetDateTime to = parsedChanges.get(i + 1).from();
                if (!current.from().isBefore(to)) {
                    continue;
                }
                intervalNo++;
                intervals.add(new ExtractedCardActivityInterval(
                        "ACT-" + intervalNo,
                        current.from(),
                        to,
                        current.activityType(),
                        current.slot(),
                        current.cardStatus(),
                        current.drivingStatus(),
                        null,
                        null,
                        current.rawRecordPath()
                ));
            }
        }
        intervals.sort(Comparator.comparing(ExtractedCardActivityInterval::from));
        return intervals;
    }

    private List<ExtractedCardActivityInterval> assignVehicleCoverage(
            List<ExtractedCardActivityInterval> activityIntervals,
            List<ExtractedCardVehicleUsageInterval> vehicleUsageIntervals
    ) {
        List<ExtractedCardActivityInterval> result = new ArrayList<>(activityIntervals.size());
        int usageStartIndex = 0;
        for (ExtractedCardActivityInterval interval : activityIntervals) {
            while (usageStartIndex < vehicleUsageIntervals.size()
                    && !usageEndExclusive(vehicleUsageIntervals.get(usageStartIndex), interval.to()).isAfter(interval.from())) {
                usageStartIndex++;
            }

            int overlapEndIndex = usageStartIndex;
            while (overlapEndIndex < vehicleUsageIntervals.size()
                    && vehicleUsageIntervals.get(overlapEndIndex).from().isBefore(interval.to())) {
                overlapEndIndex++;
            }

            result.addAll(splitByVehicleCoverage(
                    interval,
                    vehicleUsageIntervals.subList(usageStartIndex, overlapEndIndex)
            ));
        }
        return result;
    }

    private List<ExtractedCardActivityInterval> splitByVehicleCoverage(
            ExtractedCardActivityInterval interval,
            List<ExtractedCardVehicleUsageInterval> overlappingUsages
    ) {
        TreeSet<OffsetDateTime> cutPoints = new TreeSet<>();
        cutPoints.add(interval.from());
        cutPoints.add(interval.to());

        for (ExtractedCardVehicleUsageInterval usage : overlappingUsages) {
            if (usage.from().isAfter(interval.from()) && usage.from().isBefore(interval.to())) {
                cutPoints.add(usage.from());
            }
            OffsetDateTime usageEndExclusive = usageEndExclusive(usage, interval.to());
            if (usageEndExclusive.isAfter(interval.from()) && usageEndExclusive.isBefore(interval.to())) {
                cutPoints.add(usageEndExclusive);
            }
        }

        List<OffsetDateTime> orderedCutPoints = List.copyOf(cutPoints);
        List<ExtractedCardActivityInterval> segments = new ArrayList<>(Math.max(1, orderedCutPoints.size() - 1));
        int coverageIndex = 0;
        for (int i = 0; i < orderedCutPoints.size() - 1; i++) {
            OffsetDateTime segmentFrom = orderedCutPoints.get(i);
            OffsetDateTime segmentTo = orderedCutPoints.get(i + 1);
            if (!segmentFrom.isBefore(segmentTo)) {
                continue;
            }

            while (coverageIndex < overlappingUsages.size()
                    && !usageEndExclusive(overlappingUsages.get(coverageIndex), interval.to()).isAfter(segmentFrom)) {
                coverageIndex++;
            }
            ExtractedCardVehicleUsageInterval covering = coverageIndex < overlappingUsages.size()
                    && covers(overlappingUsages.get(coverageIndex), segmentFrom)
                    ? overlappingUsages.get(coverageIndex)
                    : null;
            String intervalId = orderedCutPoints.size() == 2 ? interval.intervalId() : interval.intervalId() + "-" + (i + 1);
            segments.add(new ExtractedCardActivityInterval(
                    intervalId,
                    segmentFrom,
                    segmentTo,
                    interval.activityType(),
                    interval.slot(),
                    interval.cardStatus(),
                    interval.drivingStatus(),
                    covering == null ? null : covering.registrationKey(),
                    covering == null ? null : covering.vehicleKey(),
                    interval.rawRecordPath()
            ));
        }
        return segments;
    }

    private boolean covers(ExtractedCardVehicleUsageInterval usage, OffsetDateTime timestamp) {
        return !usage.from().isAfter(timestamp) && timestamp.isBefore(usageEndExclusive(usage, null));
    }

    private OffsetDateTime usageEndExclusive(ExtractedCardVehicleUsageInterval usage, OffsetDateTime fallbackExclusiveEnd) {
        if (usage.to() == null) {
            return fallbackExclusiveEnd == null ? OffsetDateTime.MAX : fallbackExclusiveEnd;
        }
        return usage.to().plusSeconds(1);
    }

    private Element child(Element parent, String name) {
        if (parent == null) {
            return null;
        }
        NodeList childNodes = parent.getChildNodes();
        for (int i = 0; i < childNodes.getLength(); i++) {
            Node child = childNodes.item(i);
            if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) {
                return (Element) child;
            }
        }
        return null;
    }

    private List<Element> children(Element parent, String name) {
        if (parent == null) {
            return List.of();
        }
        NodeList childNodes = parent.getChildNodes();
        List<Element> children = new ArrayList<>();
        for (int i = 0; i < childNodes.getLength(); i++) {
            Node child = childNodes.item(i);
            if (child.getNodeType() == Node.ELEMENT_NODE && name.equals(child.getNodeName())) {
                children.add((Element) child);
            }
        }
        return children;
    }

    private String childText(Element parent, String name) {
        Element child = child(parent, name);
        if (child == null) {
            return null;
        }
        String value = child.getTextContent();
        return value == null || value.isBlank() ? null : value.trim();
    }

    private OffsetDateTime offsetDateTime(String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        return OffsetDateTime.parse(value.trim()).withOffsetSameInstant(ZoneOffset.UTC);
    }

    private LocalDate localDate(Element dateElement) {
        if (dateElement == null) {
            return null;
        }
        String year = childText(dateElement, "year");
        String month = childText(dateElement, "month");
        String day = childText(dateElement, "day");
        if (year == null || month == null || day == null) {
            return null;
        }
        return LocalDate.of(Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day));
    }

    private OffsetDateTime combine(LocalDate date, String timeText) {
        return TachographTimeParser.combineUtc(date, timeText);
    }

    private Long longValue(String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        return Long.parseLong(value.trim());
    }

    private String normalizeActivity(String value) {
        String normalized = normalizeToken(value);
        if (normalized == null) {
            return "UNKNOWN_ACTIVITY";
        }
        return switch (normalized) {
            case "DRIVING", "DRIVE" -> "DRIVE";
            case "WORK" -> "WORK";
            case "AVAILABILITY", "AVAILABLE" -> "AVAILABILITY";
            case "BREAK_REST", "BREAK/REST", "REST" -> "BREAK_REST";
            default -> "UNKNOWN_ACTIVITY";
        };
    }

    private String normalizeToken(String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        return value.trim().toUpperCase().replace('-', '_').replace(' ', '_');
    }

    private record ActivityChange(
            OffsetDateTime from,
            String activityType,
            String slot,
            String cardStatus,
            String drivingStatus,
            String rawRecordPath
    ) {
    }

    private String joinCardNumber(Element identification) {
        Element cardIdentification = child(identification, "cardIdentification");
        Element cardNumber = child(cardIdentification, "cardNumber");
        String driverIdentification = childText(cardNumber, "driverIdentification");
        if (driverIdentification == null) {
            return null;
        }
        String replacement = childText(cardNumber, "cardReplacementIndex");
        String renewal = childText(cardNumber, "cardRenewalIndex");
        StringBuilder builder = new StringBuilder(driverIdentification);
        if (replacement != null) {
            builder.append(replacement);
        }
        if (renewal != null) {
            builder.append(renewal);
        }
        return builder.toString();
    }
}
