package com.customer.work.model;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * Maps an Excel workbook (created with Excel or LibreOffice) to test data rows.
 * Sheet layout:
 * - The 1st row of every sheet contains the path of each variable.
 *   Object fields are separated by ".", array elements are addressed with "[index]",
 *   e.g. "root.param[0]" or "root.items[1].name".
 * - The 2nd row contains the type of each variable:
 *   String, Boolean, Integer, Long, Double, Date
 * - All other rows contain the values. Empty cells are skipped.
 *   Special String values: __NULL, __N_A, __EMPTY, __BLANK
 */
@Component
public class FlowableExcelMapper {

    protected final ObjectMapper objectMapper = new ObjectMapper();
    protected final FlowableExcelParser flowableExcelParser;

    protected static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    protected static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public FlowableExcelMapper(FlowableExcelParser flowableExcelParser) {
        this.flowableExcelParser = flowableExcelParser;
        // Enable ObjectMapper for handling Instant as string
        objectMapper.registerModule(new JavaTimeModule());
        objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
    }

    // Book as JSON: one ArrayNode per sheet, one node per data row
    public ArrayNode excelBookResourceToJsonNode(String resourcePath) {
        ArrayNode bookNode = objectMapper.createArrayNode();
        for (List<Map<String, Object>> sheet : excelBookResourceToObj(resourcePath)) {
            ArrayNode sheetNode = objectMapper.createArrayNode();
            for (Map<String, Object> row : sheet) {
                sheetNode.add(objectMapper.valueToTree(row));
            }
            bookNode.add(sheetNode);
        }
        return bookNode;
    }

    // Book as plain Java objects: one list per sheet, one map per data row
    public List<List<Map<String, Object>>> excelBookResourceToObj(String resourcePath) {
        List<List<Map<String, Object>>> book = new ArrayList<>();
        for (List<List<String>> sheet : flowableExcelParser.parseExcelBookFromResource(resourcePath)) {
            book.add(sheetToRows(sheet));
        }
        return book;
    }

    protected List<Map<String, Object>> sheetToRows(List<List<String>> rows) {
        List<Map<String, Object>> dataRows = new ArrayList<>();
        if (rows.size() < 2) {
            // A sheet needs at least the path row and the type row to contain data
            return dataRows;
        }
        List<String> paths = rows.get(0);
        List<String> types = rows.get(1);
        for (int rowNum = 2; rowNum < rows.size(); rowNum++) {
            dataRows.add(rowToMap(paths, types, rows.get(rowNum)));
        }
        return dataRows;
    }

    protected Map<String, Object> rowToMap(List<String> paths, List<String> types, List<String> row) {
        Map<String, Object> rowMap = new LinkedHashMap<>();
        for (int cellNum = 0; cellNum < row.size(); cellNum++) {
            String cellValue = row.get(cellNum);
            // Empty cells are not added
            if (cellValue != null && !cellValue.isEmpty()) {
                Object content = parseCellValue(types.get(cellNum), cellValue);
                setValue(rowMap, parsePath(paths.get(cellNum)), content);
            }
        }
        return rowMap;
    }

    protected Object parseCellValue(String type, String cellValue) {
        return switch (cellValue) {
            case "__NULL" -> null;
            case "__N_A" -> "__N_A";
            default -> switch (type) {
                case "String" -> switch (cellValue) {
                    case "__EMPTY" -> "";
                    case "__BLANK" -> " ";
                    default -> cellValue;
                };
                case "Boolean" -> Boolean.parseBoolean(cellValue);
                case "Integer" -> Integer.parseInt(cellValue);
                case "Long" -> Long.parseLong(cellValue);
                case "Double" -> Double.parseDouble(cellValue);
                case "Date" -> parseDate(cellValue);
                default -> throw new IllegalArgumentException("Variable type not supported: " + type);
            };
        };
    }

    protected Instant parseDate(String cellValue) {
        // Excel and LibreOffice consider dates as local dates, the tests treat them as UTC
        try {
            return LocalDateTime.parse(cellValue, DATE_TIME_FORMAT).toInstant(ZoneOffset.UTC);
        } catch (DateTimeParseException e) {
            return LocalDate.parse(cellValue, DATE_FORMAT).atStartOfDay(ZoneOffset.UTC).toInstant();
        }
    }

    /**
     * Splits a path like "root.items[1].name" into the tokens "root", "items", 1, "name".
     * Field names are Strings, array indexes are Integers.
     */
    protected List<Object> parsePath(String path) {
        List<Object> tokens = new ArrayList<>();
        int pos = 0;
        while (pos < path.length()) {
            char c = path.charAt(pos);
            if (c == '[') {
                int end = path.indexOf(']', pos);
                if (end < 0) {
                    throw new IllegalArgumentException("Missing ']' in path: " + path);
                }
                try {
                    tokens.add(Integer.parseInt(path.substring(pos + 1, end)));
                } catch (NumberFormatException e) {
                    throw new IllegalArgumentException("Invalid array index in path: " + path, e);
                }
                pos = end + 1;
                // An index may be followed by ".field", another "[index]" or the end of the path
                if (pos < path.length() && path.charAt(pos) == '.') {
                    pos++;
                }
            } else {
                if (c == '.') {
                    pos++;
                }
                int end = pos;
                while (end < path.length() && path.charAt(end) != '.' && path.charAt(end) != '[') {
                    end++;
                }
                if (end == pos) {
                    throw new IllegalArgumentException("Empty field name in path: " + path);
                }
                tokens.add(path.substring(pos, end));
                pos = end;
            }
        }
        if (tokens.isEmpty() || !(tokens.getFirst() instanceof String)) {
            throw new IllegalArgumentException("Path must start with a field name: " + path);
        }
        return tokens;
    }

    /**
     * Sets a value in the nested map/list structure, creating intermediate
     * containers as needed. Missing array elements are padded with empty
     * maps because Flowable cannot store null list elements as variables.
     */
    @SuppressWarnings("unchecked")
    protected void setValue(Map<String, Object> rowMap, List<Object> tokens, Object value) {
        Object current = rowMap;
        for (int i = 0; i < tokens.size(); i++) {
            Object token = tokens.get(i);
            boolean last = i == tokens.size() - 1;
            if (token instanceof String key) {
                if (!(current instanceof Map)) {
                    throw new IllegalArgumentException("Conflicting paths: '" + key + "' in " + tokens + " addresses an array as an object");
                }
                Map<String, Object> map = (Map<String, Object>) current;
                if (last) {
                    map.put(key, value);
                } else {
                    Object child = map.get(key);
                    if (child == null) {
                        child = newContainer(tokens.get(i + 1));
                        map.put(key, child);
                    }
                    current = child;
                }
            } else {
                int index = (Integer) token;
                if (!(current instanceof List)) {
                    throw new IllegalArgumentException("Conflicting paths: index " + index + " in " + tokens + " addresses an object as an array");
                }
                List<Object> list = (List<Object>) current;
                while (list.size() <= index) {
                    list.add(new LinkedHashMap<String, Object>());
                }
                if (last) {
                    list.set(index, value);
                } else {
                    current = list.get(index);
                }
            }
        }
    }

    protected Object newContainer(Object nextToken) {
        return nextToken instanceof Integer ? new ArrayList<>() : new LinkedHashMap<String, Object>();
    }

}
