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

import static de.srsoftware.umbrella.core.ConnectionProvider.connect;
import static de.srsoftware.umbrella.core.Errors.FAILED_TO_LOAD_OBJECT_BY_ID;
import static de.srsoftware.umbrella.core.ModuleRegistry.*;
import static de.srsoftware.umbrella.core.Util.mapValues;
import static de.srsoftware.umbrella.core.constants.Field.*;
import static de.srsoftware.umbrella.core.constants.Field.TASKS;
import static de.srsoftware.umbrella.core.constants.Path.*;
import static de.srsoftware.umbrella.core.constants.Text.TIME_WITH_ID;
import static de.srsoftware.umbrella.core.exceptions.UmbrellaException.*;
import static de.srsoftware.umbrella.core.model.Time.State.Open;
import static de.srsoftware.umbrella.core.model.Time.State.Started;
import static de.srsoftware.umbrella.core.model.Translatable.t;
import static de.srsoftware.umbrella.time.Constants.*;

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.*;
import de.srsoftware.umbrella.core.constants.Text;
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
import de.srsoftware.umbrella.core.model.*;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
import org.json.JSONArray;

public class TimeModule extends BaseHandler implements TimeService {

	private final TimeDb timeDb;

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

	private boolean accessible(Time time, UmbrellaUser user) {
		return time.userId() == user.id();
	}

	private boolean deleteTime(UmbrellaUser user, long timeId, HttpExchange ex) throws IOException {
		var time = timeDb.load(timeId);
		if (time.userId() != user.id()) throw forbidden("You are not allowed to delete this time!");
		timeDb.delete(timeId);
		return sendContent(ex,time);
	}


	@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();
			var timeId = Long.parseLong(head);
			return deleteTime(user.get(),timeId,ex);
		} catch (NumberFormatException e){
			return send(ex,invalidField(TIME_ID,"long value"));
		} 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 STARTED -> getStartedTime(user.get(),ex);
				case null -> super.doGet(path,ex);
				default -> {
					try {
						yield sendContent(ex,timeDb.load(Long.parseLong(head)));
					} 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 {
			Optional<Token> token = SessionToken.from(ex).map(Token::of);
			var user = userService().loadUser(token);
			if (user.isEmpty()) return unauthorized(ex);

			var head = path.pop();

			if (head == null){
				return patchTimes(user.get(),ex);
			} else try {
				var timeId = Long.parseLong(head);
				return patchTime(user.get(), timeId, ex);
			} catch (NumberFormatException e) {
				return send(ex, invalidField(TIME_ID, "long value"));
			}
		} catch (UmbrellaException e){
			return send(ex,e);
		}
	}

	@Override
	public boolean doPost(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 JOIN -> joinTimes(ex,user.get());
				case LIST -> listTimes(ex,user.get());
				case SEARCH -> postSearch(ex, user.get());
				case TRACK_TASK -> trackTask(user.get(),path,ex);
				case null -> postUserTimeList(user.get(),ex);
				default -> {
					try {
						long timeId = Long.parseLong(head);
						if (STOP.equals(path.pop())) yield getStoppedTask(user.get(),timeId,ex);
					} catch (Exception ignored) {}
					yield super.doGet(path,ex);
				}
			};
		} catch (UmbrellaException e){
			return send(ex,e);
		}
	}

	private boolean joinTimes(HttpExchange ex, UmbrellaUser user) throws IOException {
		var parts = body(ex).split("\\+");
		if (parts.length != 2) throw unprocessable("Expected two time ids as body");
		long id1, id2;
		try {
			id1 = Long.parseLong(parts[0]);
			id2 = Long.parseLong(parts[1]);
		} catch (NumberFormatException e) {
			throw unprocessable("Expected two time ids as body");
		}
		var time1 = timeDb.load(id1);
		if (time1.userId() != user.id()) throw notAmember(t(TIME_WITH_ID, ID, time1.id()));
		if (time1.state() != Open) throw forbidden("Time is not editable");
		var time2 = timeDb.load(id2);
		if (time2.userId() != user.id()) throw forbidden("You are not owner of time {id}", ID,time2.id());
		if (time2.state() != Open) throw forbidden("Time is not editable");

		if (time1.start() > time2.start()) {
			var dummy = time1;
			time1 = time2;
			time2 = dummy;
		}
		if (Math.abs(time1.end() - time2.start())>100L) throw unprocessable("Times are not adjacent");
		var subject = time1.subject();
		if (time2.subject().contains(subject)) {
			subject = time2.subject();
		} else if (!subject.contains(time2.subject())) subject += " / "+time2.subject();

		var description = time1.description();
		if (time2.description().contains(description)) {
			description = time2.description();
		} else if (!description.contains(time2.description())) description += "\n"+time2.description();

		time1.end(time2.end());
		time1.taskIds().addAll(time2.taskIds());
		time1.subject(subject);
		time1.description(description);
		var result = timeDb.save(time1);
		timeDb.delete(time2.id());
		return sendContent(ex,result);
	}

	private Optional<Time> getStartedTime(UmbrellaUser user){
        return timeDb.listUserTimes(user.id(), false).values()
                .stream()
                .filter(time -> time.state() == Started)
                .max(Comparator.comparing(Time::start));
    }

	private boolean getStoppedTask(UmbrellaUser user, long timeId, HttpExchange ex) throws IOException {
		long now;
		try {
			now = Long.parseLong(body(ex));
		} catch (NumberFormatException e) {
			throw unprocessable("request body does not contain a timestamp!");
		}
		var time = timeDb.load(timeId);
		timeDb.save(time.stop(now));
		return sendContent(ex,time);
	}

    private boolean getStartedTime(UmbrellaUser user, HttpExchange ex) throws IOException {
        var startedTime = getStartedTime(user);
        if (startedTime.isPresent()) return sendContent(ex,startedTime.get());
        return send(ex, UmbrellaException.notFound("no started time"));
    }

	private boolean listTimes(HttpExchange ex, UmbrellaUser user) throws IOException, UmbrellaException {
		var json = json(ex);
		if (!(json.has(COMPANY_ID) && json.get(COMPANY_ID) instanceof Number cid)) throw missingField(COMPANY_ID);
		var companyId = cid.longValue();
		var company = companyService().get(companyId);
		if (!companyService().membership(companyId,user.id())) throw notAmember(company.name());
		if (!(json.has(PROJECT_ID) && json.get(PROJECT_ID) instanceof Number pid)) throw missingField(PROJECT_ID);
		long projectId = pid.longValue();
		Map<Long,Task> tasksOfProject = taskService().listProjectTasks(projectId);

		List<Map<String, Object>> times = timeDb.listTimes(tasksOfProject.keySet(), true)
				.entrySet().stream()
				.filter(entry -> !entry.getValue().isClosed())
				.sorted(Comparator.comparing(entry ->entry.getValue().start()))
				.map(entry -> {
					var time = entry.getValue();
					var map = time.toMap();
					var timeTasks = time.taskIds().stream().map(tasksOfProject::get).map(Task::toMap).toList();
					map.put(TASKS,timeTasks);
					return map;
				}).toList();

		return sendContent(ex,times);

	}

	private boolean patchTime(UmbrellaUser user, long timeId, HttpExchange ex) throws IOException {
		var time = timeDb.load(timeId);
		if (!accessible(time, user)) throw forbidden("You are not allowed to alter this time!");
		var json = json(ex);
		timeDb.save(time.patch(json));
		return sendContent(ex,time);
	}

	private boolean patchTimes(UmbrellaUser user, HttpExchange ex) throws IOException {
		var json = json(ex);
		if (!json.has(IDS) || !(json.get(IDS) instanceof JSONArray ids)) throw missingField(IDS);
		var times = new HashSet<Time>();
		for (var o : ids.toList()){
			if (!(o instanceof Number id)) throw unprocessable("IDS list contains {object}, which is not a valid ID!", OBJECT,o);
			var time = timeDb.load(id.longValue());
			if (!accessible(time,user)) throw forbidden("Time list contains id ({id}) of time ({object}) which you are not allowed to edit!", ID,o, OBJECT,time.subject());
			times.add(time);
		}
		for (var time : times) timeDb.save(time.patch(json));
		return sendContent(ex, times.stream().collect(Collectors.toMap(Time::id,Time::toMap)));
	}

	private boolean postSearch(HttpExchange ex, UmbrellaUser user) throws IOException {
		var json = json(ex);
		if (!(json.has(KEY) && json.get(KEY) instanceof String key)) throw missingField(KEY);
		var keys = Arrays.asList(key.split(" "));
		var fulltext = json.has(FULLTEXT) && json.get(FULLTEXT) instanceof Boolean val && val;
		var notes = timeDb.find(user.id(),keys,fulltext);
		return sendContent(ex,mapValues(notes));
	}

	private boolean postUserTimeList(UmbrellaUser user, HttpExchange ex) throws IOException {
		var json = json(ex);
		var showClosed = json.has(CLOSED) && json.get(CLOSED) instanceof Boolean b ? b : false;
		Set<Long> taskIds = new HashSet<>();
		Map<Long, Project> projects = projectService().listUserProjects(user.id(), true);
		for (var pid : projects.keySet()) taskIds.addAll(taskService().listProjectTasks(pid).keySet());
		var times = timeDb.listTimes(taskIds, showClosed);
		times.putAll(timeDb.listUserTimes(user.id(), showClosed));
		taskIds.clear();
		var userIds = new HashSet<Long>();
		times.values().forEach(time -> {
			taskIds.addAll(time.taskIds());
			userIds.add(time.userId());
		});
		var tasks = taskService().load(taskIds);
		var users = userService().list(null,null,userIds);
		var result = new HashMap<String,Object>();
		var docList = documentService().docsReferencedByTimes(times.keySet());
		result.put(DOCUMENTS,docList);
		result.put(TIMES,mapValues(times));
		result.put(TASKS,mapValues(tasks));
		result.put(PROJECTS,mapValues(projects));
		result.put(USERS,mapValues(users));
		return sendContent(ex,result);
	}

	private boolean trackTask(UmbrellaUser user, Path path, HttpExchange ex) throws IOException {
		if (path.empty()) throw missingField(TASK_ID);
		Task task;
		try {
			var taskId = Long.parseLong(path.pop());
			task = taskService().load(List.of(taskId)).get(taskId);
			if (task == null) throw UmbrellaException.notFound(FAILED_TO_LOAD_OBJECT_BY_ID, OBJECT,t(Text.TASK), ID,taskId);
		} catch (NumberFormatException e) {
			throw invalidField(TASK_ID,"long value");
		}

		long now;
		try {
			now = Long.parseLong(body(ex));
		} catch (NumberFormatException e) {
			throw unprocessable("request body does not contain a timestamp!");
		}

		var opt = getStartedTime(user);
		if (opt.isPresent()){
			var startedTime = opt.get();

			if (startedTime.taskIds().contains(task.id())) {
				// if the time started last already belongs to the task, there is nothing left to do
				return sendContent(ex,startedTime);
			}
			timeDb.save(startedTime.stop(now));
		}
		var track = new Time(0,user.id(),task.name(),task.description(),now,null,Started,List.of(task.id()));
		timeDb.save(track);

		return sendContent(ex,track);
	}

	@Override
	public void updateStates(Collection<Long> timeIds, Time.State timeState) {
		timeDb.updateStates(timeIds,timeState);
	}
}
