/* © SRSoftware 2025 */
package de.srsoftware.umbrella.accounting;

import static de.srsoftware.tools.Optionals.nullIfEmpty;
import static de.srsoftware.umbrella.accounting.Constants.CONFIG_DATABASE;
import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
import static de.srsoftware.umbrella.core.ModuleRegistry.tagService;
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
import static de.srsoftware.umbrella.core.constants.Path.*;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
import static de.srsoftware.umbrella.messagebus.events.Event.EventType.CREATE;

import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.configuration.Configuration;
import de.srsoftware.tools.Path;
import de.srsoftware.tools.SessionToken;
import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.ModuleRegistry;
import de.srsoftware.umbrella.core.api.AccountingService;
import de.srsoftware.umbrella.core.constants.Field;
import de.srsoftware.umbrella.core.constants.Text;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.*;
import de.srsoftware.umbrella.messagebus.events.Event;
import de.srsoftware.umbrella.messagebus.events.TransactionEvent;
import java.io.IOException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
import org.json.JSONArray;
import org.json.JSONObject;

public class AccountingModule extends BaseHandler implements AccountingService {
	private final AccountDb accountDb;

	public AccountingModule(Configuration config) throws UmbrellaException {
		super();
		var dbFile = config.get(CONFIG_DATABASE).orElseThrow(() -> missingField(CONFIG_DATABASE));
		accountDb = new SqliteDb(connect(dbFile));
		ModuleRegistry.add(this);
	}

	@Override
	public boolean doDelete(Path path, HttpExchange ex) throws IOException {
		addCors(ex);
		try {
			Optional<Token> token = SessionToken.from(ex).map(Token::of);
			var user = userService().loadUser(token);
			if (user.isEmpty()) return unauthorized(ex);
			var head = path.pop();
			return switch (head) {
				case TRANSACTION -> {
					try {
						var transaction = accountDb.loadTransaction(Long.parseLong(path.pop()));
						if (!accountDb.getMembers(transaction.accountId()).contains(user.get())) throw forbidden("You are not allowed to access account {id}",Field.ID,transaction.accountId());
						yield dropTransaction(transaction, user.get(), path, ex);
					} catch (NumberFormatException ignored) {
						yield super.doDelete(path,ex);
					}
				}
				case null, default -> super.doDelete(path, ex);
			};
		} catch (UmbrellaException e){
			return send(ex,e);
		}
	}

	@Override
	public boolean doGet(Path path, HttpExchange ex) throws IOException {
		addCors(ex);
		try {
			Optional<Token> token = SessionToken.from(ex).map(Token::of);
			var user = userService().loadUser(token);
			if (user.isEmpty()) return unauthorized(ex);
			var head = path.pop();
			return switch (head) {
				case null -> getAccounts(user.get(),ex);
				default -> {
					try {
						yield getAccount(user.get(),Long.parseLong(head),ex);
					} catch (NumberFormatException ignored) {}
					yield super.doGet(path,ex);
				}
			};
		} catch (UmbrellaException e){
			return send(ex,e);
		}
	}

	@Override
	public boolean doPatch(Path path, HttpExchange ex) throws IOException {
		addCors(ex);
		try {
			var user = userService().refreshSession(ex);
			if (user.isEmpty()) return unauthorized(ex);
			var head = path.pop();
			return switch (head) {
				case TRANSACTION -> {
					try {
						var tid = Long.parseLong(path.pop());
						yield patchTransaction(user.get(),tid,ex);
					} catch (NumberFormatException ignored) {
						yield super.doPatch(path,ex);
					}

				}
				case null, default -> super.doPatch(path, ex);
			};
		} catch (UmbrellaException e){
			return send(ex,e);
		}
	}

	@Override
	public boolean doPost(Path path, HttpExchange ex) throws IOException {
		addCors(ex);
		try {
			var user = userService().refreshSession(ex);
			if (user.isEmpty()) return unauthorized(ex);
			var head = path.pop();
			return switch (head) {
				case null -> postEntry(user.get(),ex);
 				case DESTINATIONS -> postSearchDestinations(user.get(),ex);
				case PURPOSES -> postSearchPurposes(user.get(),ex);
				case SOURCES -> postSearchSources(user.get(),ex);
				default -> {
					try {
						var accountId = Long.parseLong(head);
						if (!accountDb.getMembers(accountId).contains(user.get())) throw forbidden("You are not allowed to access account {id}",Field.ID,accountId);
						yield postToAccount(accountId,path,user.get(),ex);
					} catch (NumberFormatException ignored) {
						yield super.doPost(path,ex);
					}

				}
			};
		} catch (UmbrellaException e){
			return send(ex,e);
		}
	}

	public boolean dropTransaction(Transaction transaction, UmbrellaUser user, HttpExchange ex) throws IOException {
		var dropped = accountDb.dropTransaction(transaction);
		messageBus().dispatch(new TransactionEvent(user,dropped,Event.EventType.DELETE));
		return sendContent(ex,dropped);
	}

	public boolean dropTransaction(Transaction transaction, UmbrellaUser user, Path path, HttpExchange ex) throws IOException {
		var head = path.pop();
		return switch (head){
			case TAG -> dropTransactionTag(user,transaction,ex);
			case null -> dropTransaction(transaction,user,ex);
			default -> super.doDelete(path,ex);
		};
	}

	private boolean dropTransactionTag(UmbrellaUser user, Transaction transaction, HttpExchange ex) throws IOException {
		var json = json(ex);
		if (!json.has(Field.TAG)) throw missingField(Field.TAG);
		var tag = json.getString(Field.TAG);
		if (tag.isBlank()) throw invalidField(Field.TAG,"non-empty");
		accountDb.dropTransactionTag(transaction.id(),tag);
		transaction.tags().remove(tag);
		return sendContent(ex, transaction);
	}

	private List<String> egalize(Set<String> tags, String key) {
		var result = new HashSet<String>();
		var lower  = key.toLowerCase();
		var len    = key.length();
		for (var tag : tags) {
			if (tag.length() == key.length()) continue;
			result.add(tag.toLowerCase().startsWith(lower) ? key + tag.substring(len) : tag);
		}
		return result.stream().sorted(String.CASE_INSENSITIVE_ORDER).toList();
	}

	private String extractParty(String field, JSONObject json){
		if (!json.has(field)) return null;
		if (!(json.get(field) instanceof JSONObject data)) return null;
		if (data.has(Field.ID)) return data.get(Field.ID).toString();
		if (data.has(Field.DISPLAY)) return data.get(Field.DISPLAY).toString();
		return null;
	}

	private boolean getAccount(UmbrellaUser user, long accountId, HttpExchange ex) throws IOException {
		if (!accountDb.getMembers(accountId).contains(user)) throw forbidden("You are not allowed to access account {id}",Field.ID,accountId);
		return sendContent(ex, loadAccount(accountId));
	}

	private boolean getAccounts(UmbrellaUser user, HttpExchange ex) throws IOException {
		return sendContent(ex,accountDb.listAccounts(user.id()).stream().map(Account::toMap));
	}

	public AccountData loadAccount(long accountId){
		var account      = accountDb.loadAccount(accountId);
		var transactions = accountDb.loadTransactions(account);
		var userMap      = new HashMap<Long,UmbrellaUser>();

		for (var transaction : transactions){
			var source = transaction.source();
			if (source.isId()) {
				var userId = source.id();
				if (!userMap.containsKey(userId)) userMap.put(source.id(),userService().loadUser(userId));
			}
			var destination = transaction.destination();
			if (destination.isId()) {
				var userId = destination.id();
				if (!userMap.containsKey(userId)) userMap.put(destination.id(),userService().loadUser(userId));
			}
		}
		return AccountData.of(account, transactions, userMap);
	}

	private static boolean noNumbers(String s){
		try {
			Long.parseLong(s);
			return false;
		} catch (NumberFormatException e) {
			return true;
		}
	}

	private boolean patchTransaction(UmbrellaUser user, long transactionId, HttpExchange ex) throws IOException {
		var transaction = accountDb.loadTransaction(transactionId);
		if (!accountDb.getMembers(transaction.accountId()).contains(user)) throw forbidden("You are not allowed to access account {id}",Field.ID,transaction.accountId());
		var oldData = transaction.toMap();
		var json = json(ex);
		if (json.has(Field.AMOUNT)) transaction.amount(json.getDouble(Field.AMOUNT));
		if (json.has(Field.DATE)) transaction.date(LocalDate.parse(json.getString(Field.DATE)));
		if (json.has(Field.DESTINATION)) transaction.destination(IdOrString.of(json.getString(Field.DESTINATION)));
		if (json.has(Field.PURPOSE)) transaction.purpose(json.getString(Field.PURPOSE));
		if (json.has(Field.SOURCE)) transaction.source(IdOrString.of(json.getString(Field.SOURCE)));
		if (json.has(Field.TAG)) transaction.tags().add(json.getString(Field.TAG));
		var patched = accountDb.save(transaction);
		messageBus().dispatch(new TransactionEvent(user,patched,oldData));
		return sendContent(ex,patched);
	}

	private boolean postEntry(UmbrellaUser user, HttpExchange ex) throws IOException {
		var json = json(ex);
		if (!json.has(Field.ACCOUNT)) throw missingField(Field.ACCOUNT);
		if (!(json.get(Field.ACCOUNT) instanceof JSONObject acc)) throw invalidField(Field.ACCOUNT,JSON);

		if (!json.has(Field.DATE)) throw missingField(Field.DATE);
		var date = LocalDate.parse(json.getString(Field.DATE));
		var dateTime = LocalDateTime.now().withYear(date.getYear()).withMonth(date.getMonthValue()).withDayOfMonth(date.getDayOfMonth());
		if (!json.has(Field.AMOUNT)) throw missingField(Field.AMOUNT);
		if (!(json.get(Field.AMOUNT) instanceof Number amount)) throw invalidField(Field.AMOUNT,Text.NUMBER);

		var purpose = json.has(Field.PURPOSE) ? json.getString(Field.PURPOSE) : null;

		if (!json.has(Field.SOURCE)) throw missingField(Field.SOURCE);
		if (!(json.get(Field.SOURCE) instanceof JSONObject sourceData)) throw invalidField(Field.SOURCE,JSON);
		if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
		if (!(json.get(Field.DESTINATION) instanceof JSONObject destinationData)) throw invalidField(Field.DESTINATION,JSON);

		IdOrString source = null, destination = null;

		if (sourceData.has(Field.ID)) {
			if (!(sourceData.get(Field.ID) instanceof Number uid)) throw invalidField(String.join(".",Field.SOURCE,Field.ID),Text.NUMBER);
			source = IdOrString.of(userService().loadUser(uid.longValue()));
		} else {
			if (!sourceData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.SOURCE,Field.DISPLAY));
			source = IdOrString.of(sourceData.getString(Field.DISPLAY));
			if (source.value().isBlank()) throw invalidField(String.join(".",Field.SOURCE,Field.DISPLAY),Text.STRING);
		}

		if (destinationData.has(Field.ID)) {
			if (!(destinationData.get(Field.ID) instanceof Number uid)) throw invalidField(String.join(".",Field.DESTINATION,Field.ID),Text.NUMBER);
			destination = IdOrString.of(userService().loadUser(uid.longValue()));
		} else {
			if (!destinationData.has(Field.DISPLAY)) throw missingField(String.join(".",Field.DESTINATION,Field.DISPLAY));
			destination = IdOrString.of(destinationData.getString(Field.DISPLAY));
			if (destination.value().isBlank()) throw invalidField(String.join(".",Field.DESTINATION,Field.DISPLAY),Text.STRING);
		}


		Long accountId = null;
		if (acc.has(Field.ID)) {
			if (!(acc.get(Field.ID) instanceof Number accId)) throw invalidField(String.join(".",Field.ACCOUNT,Field.ID), Text.NUMBER);
			if (accId.longValue() != 0) accountId = accId.longValue();
		}
		Account newAccount = null;
		if (accountId == null){
			if (!acc.has(Field.NAME)) throw missingField(String.join(".",Field.ACCOUNT,Field.NAME));
			var accountName = acc.getString(Field.NAME);
			if (accountName.isBlank()) throw invalidField(String.join(".",Field.ACCOUNT,Field.NAME),Text.STRING);

			var currency = acc.has(Field.CURRENCY) ? nullIfEmpty(acc.getString(Field.CURRENCY)) : null;

			newAccount = accountDb.save(new Account(0, accountName, currency, user.id()));
			accountId = newAccount.id();
		}

		var tagList = json.has(Field.TAGS) && json.get(Field.TAGS) instanceof JSONArray t ? t : List.of();
		var tags = new HashSet<String>();
		tagList.forEach(e -> tags.add(e.toString()));


		var transaction = accountDb.save(new Transaction(0,accountId,dateTime,source,destination,amount.doubleValue(),purpose,tags));
		messageBus().dispatch(new TransactionEvent(user,transaction, CREATE));
		return sendContent(ex,newAccount != null ? newAccount : transaction);
	}

	public boolean postGetLastTransaction(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
		var json = json(ex);
		if (!json.has(Field.SOURCE)) throw missingField(Field.SOURCE);
		if (!(json.get(Field.SOURCE) instanceof JSONObject src)) throw invalidField(Field.SOURCE,JSON);
		var source = src.get(src.has(Field.ID) ? Field.ID : Field.DISPLAY).toString();
		if (!json.has(Field.DESTINATION)) throw missingField(Field.DESTINATION);
		if (!(json.get(Field.DESTINATION) instanceof JSONObject dst)) throw invalidField(Field.SOURCE,JSON);
		String destination = dst.has(Field.ID) ? dst.get(Field.ID).toString() : dst.has(Field.DISPLAY) ? dst.get(Field.DISPLAY).toString() : null;
		Double amount = null;
		if (json.has(Field.AMOUNT) && json.get(Field.AMOUNT) instanceof Number amt) amount = amt.doubleValue();

		var transaction = accountDb.lastTransaction(accountId, source, destination, amount);
		return transaction.isPresent() ? sendContent(ex,transaction.get()) : notFound(ex);
	}

	public boolean postSearchDestinations(UmbrellaUser user, HttpExchange ex) throws IOException {
		return sendContent(ex,searchOptions(user, Field.DESTINATION, body(ex)));
	}

	public boolean postSearchPurposes(UmbrellaUser user, HttpExchange ex) throws IOException {
		var key = body(ex);
		var purposes = accountDb.searchField(user.id(),Field.DESCRIPTION,key);
		return sendContent(ex,purposes);
	}


	public boolean postSearchSources(UmbrellaUser user, HttpExchange ex) throws IOException {
		return sendContent(ex,searchOptions(user, Field.SOURCE, body(ex)));
	}

	private boolean postSearchTags(long accountId, UmbrellaUser user, HttpExchange ex) throws IOException {
		var key = body(ex);
		if (!key.trim().startsWith("{")) { // search tags that contain value of body
			var tags = accountDb.searchTagsContaining(key, accountId);
			if (tags.size() < 10) tags.addAll(tagService().search(key, user));
			return sendContent(ex, egalize(tags, key));
		}
		// search tags for account with specified source and destination
		var json = new JSONObject(key);
		var src = extractParty(Field.SOURCE, json);
		var dst = extractParty(Field.DESTINATION, json);
		var tags = accountDb.listTags(accountId,src,dst);
		return sendContent(ex,tags);
	}

	private boolean postToAccount(long accountId, Path path, UmbrellaUser user, HttpExchange ex) throws IOException {
		var head = path.pop();
		return switch (head) {
			case PURPOSES -> postGetLastTransaction(accountId,user,ex);
			case TAGS -> postSearchTags(accountId,user,ex);
			case null, default -> super.doPost(path,ex);
		};
	}

	public ArrayList<Map<String,?>> searchOptions(UmbrellaUser user, String field, String key){
		var users = userService().search(key);
		var destinations = accountDb.searchField(user.id(), field, key);
		var optionList = new ArrayList<Map<String,?>>();
		users.values().stream().map(UmbrellaUser::toMap).forEach(optionList::add);
		destinations.stream().filter(AccountingModule::noNumbers).map(s -> Map.of(Field.DISPLAY,s)).forEach(optionList::add);
		return optionList;
	}
}
