package demo.project.controller;

import demo.project.Commit;
import demo.project.Project;
import demo.project.ProjectService;
import demo.project.event.ProjectEvent;
import demo.project.event.ProjectEventRepository;
import demo.project.event.ProjectEventService;
import demo.project.event.ProjectEventType;
import demo.project.repository.CommitRepository;
import demo.project.repository.ProjectRepository;
import org.springframework.hateoas.LinkBuilder;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.Assert;
import org.springframework.web.bind.annotation.*;

import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;

@RestController
@RequestMapping("/v1")
public class ProjectController {

    private final ProjectRepository projectRepository;
    private final CommitRepository commitRepository;
    private final ProjectEventRepository eventRepository;
    private final ProjectService projectService;
    private final ProjectEventService projectEventService;

    public ProjectController(ProjectRepository projectRepository, CommitRepository commitRepository,
                             ProjectEventRepository eventRepository, ProjectService projectService,
                             ProjectEventService projectEventService) {
        this.projectRepository = projectRepository;
        this.commitRepository = commitRepository;
        this.eventRepository = eventRepository;
        this.projectService = projectService;
        this.projectEventService = projectEventService;
    }

    @RequestMapping(path = "/projects")
    public ResponseEntity getProjects() {
        return new ResponseEntity<>(projectRepository.findAll(), HttpStatus.OK);
    }

    @PostMapping(path = "/projects")
    public ResponseEntity createProject(@RequestBody Project project) {
        return Optional.ofNullable(createProjectResource(project))
                .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
                .orElseThrow(() -> new RuntimeException("Project creation failed"));
    }

    @PutMapping(path = "/projects/{id}")
    public ResponseEntity updateProject(@RequestBody Project project, @PathVariable Long id) {
        return Optional.ofNullable(updateProjectResource(id, project))
                .map(e -> new ResponseEntity<>(e, HttpStatus.OK))
                .orElseThrow(() -> new RuntimeException("Project update failed"));
    }

    @RequestMapping(path = "/projects/{id}")
    public ResponseEntity getProject(@PathVariable Long id) {
        return Optional.ofNullable(projectRepository.findById(id).get())
                .map(this::getProjectResource)
                .map(e -> new ResponseEntity<>(e, HttpStatus.OK))
                .orElse(new ResponseEntity<>(HttpStatus.NOT_FOUND));
    }

    @DeleteMapping(path = "/projects/{id}")
    public ResponseEntity deleteProject(@PathVariable Long id) {
        try {
            projectRepository.delete(new Project(id));
        } catch (Exception ex) {
            throw new RuntimeException("Project deletion failed");
        }

        return new ResponseEntity<>(HttpStatus.NO_CONTENT);
    }

    @RequestMapping(path = "/projects/{id}/commits")
    public ResponseEntity getProjectCommits(@PathVariable Long id) {
        return Optional.of(getProjectCommitResources(id))
                .map(e -> new ResponseEntity<>(e, HttpStatus.OK))
                .orElseThrow(() -> new RuntimeException("Could not get project commits"));
    }

    @PostMapping(path = "/projects/{id}/commits")
    public ResponseEntity appendProjectCommit(@PathVariable Long id, @RequestBody Commit commit) {
        return Optional.ofNullable(appendCommitResource(id, commit))
                .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
                .orElseThrow(() -> new RuntimeException("Append project commit failed"));
    }

    @RequestMapping(path = "/projects/{id}/events")
    public ResponseEntity getProjectEvents(@PathVariable Long id) {
        return Optional.of(getProjectEventResources(id))
                .map(e -> new ResponseEntity<>(e, HttpStatus.OK))
                .orElseThrow(() -> new RuntimeException("Could not get project events"));
    }

    @RequestMapping(path = "/projects/{id}/events/{eventId}")
    public ResponseEntity getProjectEvent(@PathVariable Long id, @PathVariable Long eventId) {
        return Optional.of(getEventResource(eventId))
                .map(e -> new ResponseEntity<>(e, HttpStatus.OK))
                .orElseThrow(() -> new RuntimeException("Could not get order events"));
    }

    @PostMapping(path = "/projects/{id}/events")
    public ResponseEntity appendProjectEvent(@PathVariable Long id, @RequestBody ProjectEvent event) {
        return Optional.ofNullable(appendEventResource(id, event))
                .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
                .orElseThrow(() -> new RuntimeException("Append project event failed"));
    }

    @RequestMapping(path = "/projects/{id}/commands")
    public ResponseEntity getCommands(@PathVariable Long id) {
        return Optional.ofNullable(getCommandsResource(id))
                .map(e -> new ResponseEntity<>(e, HttpStatus.OK))
                .orElseThrow(() -> new RuntimeException("The project could not be found"));
    }

    @PostMapping(path = "/projects/{id}/commands/commit")
    public ResponseEntity commitProject(@PathVariable Long id, @RequestBody Commit commit) {
        return Optional.ofNullable(appendCommitResource(id, commit))
                .map(e -> new ResponseEntity<>(e, HttpStatus.CREATED))
                .orElseThrow(() -> new RuntimeException("Append project commit failed"));
    }

    /**
     * Creates a new {@link Project} entity and persists the result to the repository.
     *
     * @param project is the {@link Project} model used to create a new project
     * @return a hypermedia resource for the newly created {@link Project}
     */
    private Resource<Project> createProjectResource(Project project) {
        Assert.notNull(project, "Project body must not be null");

        project = projectService.updateProject(project);
        project = projectEventService.apply(new ProjectEvent(ProjectEventType.CREATED_EVENT, project),
                projectService).getEntity();

        return getProjectResource(project);
    }

    /**
     * Update a {@link Project} entity for the provided identifier.
     *
     * @param id      is the unique identifier for the {@link Project} update
     * @param project is the entity representation containing any updated {@link Project} fields
     * @return a hypermedia resource for the updated {@link Project}
     */
    private Resource<Project> updateProjectResource(Long id, Project project) {
        project.setIdentity(id);
        return getProjectResource(projectRepository.save(project));
    }

    /**
     * Appends an {@link Commit} domain commit to the commit log of the {@link Project}
     * aggregate with the specified projectId.
     *
     * @param projectId is the unique identifier for the {@link Project}
     * @param commit    is the {@link Commit} that attempts to alter the state of the {@link Project}
     * @return a hypermedia resource for the newly appended {@link Commit}
     */
    private Resource<Commit> appendCommitResource(Long projectId, Commit commit) {
        Assert.notNull(commit, "Commit body must be provided");

        Project project = projectRepository.findById(projectId).get();
        Assert.notNull(project, "Project could not be found");

        commit.setProjectId(project.getIdentity());
        commit = commitRepository.save(commit);
        project.getCommits().add(commit);
        projectRepository.save(project);

        Map<String, Object> payload = new HashMap<>();
        payload.put("commit", commit);

        // Generate commit event
        projectEventService.apply(new ProjectEvent(ProjectEventType.COMMIT_EVENT, project, payload), projectService);

        return new Resource<>(commit,
                linkTo(CommitController.class)
                        .slash("commits")
                        .slash(commit.getIdentity())
                        .withSelfRel(),
                linkTo(ProjectController.class)
                        .slash("projects")
                        .slash(projectId)
                        .withRel("project")
        );
    }

    /**
     * Appends an {@link ProjectEvent} domain event to the event log of the {@link Project}
     * aggregate with the specified projectId.
     *
     * @param projectId is the unique identifier for the {@link Project}
     * @param event     is the {@link ProjectEvent} that attempts to alter the state of the {@link Project}
     * @return a hypermedia resource for the newly appended {@link ProjectEvent}
     */
    private Resource<ProjectEvent> appendEventResource(Long projectId, ProjectEvent event) {
        Assert.notNull(event, "Event body must be provided");

        Project project = projectRepository.findById(projectId).get();
        Assert.notNull(project, "Project could not be found");

        event.setProjectId(project.getIdentity());
        projectEventService.apply(event, projectService);

        return new Resource<>(event,
                linkTo(ProjectController.class)
                        .slash("projects")
                        .slash(projectId)
                        .slash("events")
                        .slash(event.getEventId())
                        .withSelfRel(),
                linkTo(ProjectController.class)
                        .slash("projects")
                        .slash(projectId)
                        .withRel("project")
        );
    }

    private ProjectEvent getEventResource(Long eventId) {
        return eventRepository.findById(eventId).get();
    }

    private List<ProjectEvent> getProjectEventResources(Long id) {
        return eventRepository.findEventsByProjectId(id);
    }

    private List<Commit> getProjectCommitResources(Long id) {
        return commitRepository.findCommitsByProjectId(id);
    }

    private LinkBuilder linkBuilder(String name, Long id) {
        Method method;

        try {
            method = ProjectController.class.getMethod(name, Long.class);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }

        return linkTo(ProjectController.class, method, id);
    }

    /**
     * Get a hypermedia enriched {@link Project} entity.
     *
     * @param project is the {@link Project} to enrich with hypermedia links
     * @return is a hypermedia enriched resource for the supplied {@link Project} entity
     */
    private Resource<Project> getProjectResource(Project project) {
        Assert.notNull(project, "Project must not be null");

        if (!project.hasLink("commands")) {
            // Add command link
            project.add(linkBuilder("getCommands", project.getIdentity()).withRel("commands"));
        }

        if (!project.hasLink("events")) {
            // Add get events link
            project.add(linkBuilder("getProjectEvents", project.getIdentity()).withRel("events"));
        }

        if (!project.hasLink("commits")) {
            // Add get events link
            project.add(linkBuilder("getProjectCommits", project.getIdentity()).withRel("commits"));
        }

        return new Resource<>(project);
    }

    private ResourceSupport getCommandsResource(Long id) {
        Project project = new Project();
        project.setIdentity(id);

        CommandResources commandResources = new CommandResources();

        // Add suspend command link
        commandResources.add(linkTo(ProjectController.class)
                .slash("projects")
                .slash(id)
                .slash("commands")
                .slash("commit")
                .withRel("commit"));

        return new Resource<>(commandResources);
    }

    public static class CommandResources extends ResourceSupport {
    }
}