ArchitectureEditorController.java

package com.taxonomy.editor;

import com.taxonomy.dsl.command.ArchitectureCommand.*;
import com.taxonomy.dsl.command.ArchitectureDslCommands.CommandProblem;
import com.taxonomy.editor.persistence.EditorJournal.RevisionConflict;
import com.taxonomy.editor.ArchitectureCommandPort.*;
import com.taxonomy.export.SvgDiagramRenderer;
import com.taxonomy.portfolio.workbench.ArchitecturePdfRenderer;
import com.taxonomy.relations.controller.GitHttpPrecondition;
import com.taxonomy.workspace.service.RepositoryContext;
import com.taxonomy.workspace.service.WorkspaceResolver;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

@Controller
public class ArchitectureEditorController {
    private final ArchitectureEditorService service;
    private final ArchitectureEditorProjection projection;
    private final WorkspaceResolver resolver;
    private final SvgDiagramRenderer svg;
    private final ArchitecturePdfRenderer pdf;

    public ArchitectureEditorController(ArchitectureEditorService service, ArchitectureEditorProjection projection,
                                        WorkspaceResolver resolver, SvgDiagramRenderer svg, ArchitecturePdfRenderer pdf) {
        this.service = service;
        this.projection = projection;
        this.resolver = resolver;
        this.svg = svg;
        this.pdf = pdf;
    }

    @GetMapping("/architecture/editor")
    public String page() { return "architecture-editor"; }

    @GetMapping("/api/architecture/editor")
    @ResponseBody
    public ResponseEntity<ArchitectureEditorProjection.View> read(
            @RequestParam(required = false) String repositoryId,
            @RequestParam(required = false) String workspaceScopeKey,
            @RequestParam(required = false) String branch,
            @RequestParam(required = false) String commit,
            @RequestParam(required = false) Long revision) throws IOException {
        RepositoryContext context = readContext(repositoryId, workspaceScopeKey, branch);
        var document = service.read(context, commit, revision);
        return documentResponse(document).body(projection.project(document, mayEdit()));
    }

    @GetMapping(value = "/api/architecture/editor.svg", produces = "image/svg+xml")
    @ResponseBody
    public ResponseEntity<String> svg(@RequestParam String repositoryId, @RequestParam String workspaceScopeKey,
                                      @RequestParam String branch, @RequestParam(required = false) String commit,
                                      @RequestParam(required = false) Long revision) throws IOException {
        if (commit == null && revision == null) throw new IllegalArgumentException("An exact revision or version is required for export");
        var document = service.read(readContext(repositoryId, workspaceScopeKey, branch), commit, revision);
        var view = projection.project(document, false);
        return documentResponse(document)
                .header("X-Taxonomy-Layout-Source", view.schema().layoutMode())
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=architecture.svg")
                .body(svg.render(view.scene()));
    }

    @GetMapping(value = "/api/architecture/editor.pdf", produces = "application/pdf")
    @ResponseBody
    public ResponseEntity<byte[]> pdf(@RequestParam String repositoryId, @RequestParam String workspaceScopeKey,
                                      @RequestParam String branch, @RequestParam(required = false) String commit,
                                      @RequestParam(required = false) Long revision) throws IOException {
        if (commit == null && revision == null) throw new IllegalArgumentException("An exact revision or version is required for export");
        var document = service.read(readContext(repositoryId, workspaceScopeKey, branch), commit, revision);
        var view = projection.project(document, false);
        var provenance = document.context();
        String selected = "GIT_CHECKPOINT".equals(document.source()) ? "Checkpoint " + provenance.commit()
                : "Revision " + provenance.revision() + " / checkpoint " + provenance.commit();
        return documentResponse(document)
                .header("X-Taxonomy-Layout-Source", view.schema().layoutMode())
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=architecture.pdf")
                .body(pdf.render(view.scene(), provenance.repositoryId() + " / " + provenance.workspaceScopeKey()
                        + " / " + provenance.branch() + "\n" + selected));
    }

    @PostMapping("/api/architecture/editor/preview")
    @ResponseBody
    public ResponseEntity<Preview> preview(@RequestBody WireCommand body,
            @RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch,
            @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) throws IOException {
        Command command = command(body, ifMatch, ifNoneMatch);
        Preview result = service.preview(resolver.resolveCurrentRepositoryContext(), command);
        return response(HttpStatus.OK, result.context()).body(result);
    }

    @PostMapping("/api/architecture/editor/commands")
    @ResponseBody
    public ResponseEntity<Accepted> execute(@RequestBody WireCommand body,
            @RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch,
            @RequestHeader(value = HttpHeaders.IF_NONE_MATCH, required = false) String ifNoneMatch) throws IOException {
        Accepted result = service.execute(resolver.resolveCurrentRepositoryContext(), command(body, ifMatch, ifNoneMatch));
        return response("READY".equals(result.projectionState()) ? HttpStatus.OK : HttpStatus.ACCEPTED,
                result.context()).body(result);
    }

    @PostMapping("/api/architecture/editor/rebuild")
    @ResponseBody
    public ResponseEntity<Map<String, String>> rebuild(@RequestBody Context context,
            @RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch) throws IOException {
        requireRevision(context, ifMatch, null);
        return response(HttpStatus.OK, context).body(Map.of("projectionState",
                service.rebuild(resolver.resolveCurrentRepositoryContext(), context)));
    }

    /** A bounded wire union is converted immediately to typed application/domain commands. No full graph payload exists. */
    public record WireCommand(Context context, Metadata metadata, String kind, String id, String type,
                              Map<String, String> properties, String sourceId, String relationType, String targetId,
                              String status, String parentId, String targetOperationId) {
        public WireCommand {
            if (context == null || metadata == null || kind == null) throw new IllegalArgumentException("Context, metadata and kind are required");
            if (properties != null && properties.values().stream().anyMatch(Objects::isNull)) {
                throw new IllegalArgumentException("Property values must be strings");
            }
            properties = properties == null ? Map.of() : Map.copyOf(properties);
        }
    }

    private static Command command(WireCommand body, String ifMatch, String ifNoneMatch) {
        requireRevision(body.context(), ifMatch, ifNoneMatch);
        Operation operation = switch (body.kind()) {
            case "CREATE_ELEMENT" -> new SemanticCommand(new CreateArchitectureElement("arch-" + body.metadata().commandId(), body.type(), body.properties()));
            case "UPDATE_ELEMENT" -> new SemanticCommand(new UpdateArchitectureElement(body.id(), body.type(), body.properties()));
            case "DELETE_ELEMENT" -> new SemanticCommand(new DeleteArchitectureElement(body.id()));
            case "CREATE_RELATION" -> new SemanticCommand(new CreateArchitectureRelation(relation(body), body.status()));
            case "UPDATE_RELATION" -> new SemanticCommand(new UpdateArchitectureRelation(relation(body), body.status()));
            case "DELETE_RELATION" -> new SemanticCommand(new DeleteArchitectureRelation(relation(body)));
            case "MOVE_ELEMENT" -> new SemanticCommand(new MoveOrGroupElement(body.id(), body.parentId()));
            case "UNDO" -> new UndoArchitectureCommand(requireTarget(body.targetOperationId()));
            case "REDO" -> new RedoArchitectureCommand(requireTarget(body.targetOperationId()));
            default -> throw new IllegalArgumentException("Unsupported semantic command");
        };
        return new Command(body.context(), body.metadata(), operation);
    }

    private static RelationKey relation(WireCommand body) {
        if (body.sourceId() == null || body.relationType() == null || body.targetId() == null) throw new IllegalArgumentException("Relation identity is required");
        return new RelationKey(body.sourceId(), body.relationType(), body.targetId());
    }

    private static UUID requireTarget(String target) {
        if (target == null) throw new IllegalArgumentException("Target operation ID is required");
        return UUID.fromString(target);
    }

    private RepositoryContext readContext(String repositoryId, String workspaceScopeKey, String branch) {
        RepositoryContext context = resolver.resolveCurrentRepositoryContext();
        Context current = Context.of(context, null);
        if ((repositoryId != null && !repositoryId.equals(current.repositoryId()))
                || (workspaceScopeKey != null && !workspaceScopeKey.equals(current.workspaceScopeKey()))
                || (branch != null && !branch.equals(current.branch()))) {
            throw new CommandProblem("NOT_FOUND", "context", "This exact context is not selected or accessible", List.of());
        }
        return context;
    }

    private static boolean mayEdit() {
        var authentication = SecurityContextHolder.getContext().getAuthentication();
        return authentication != null && authentication.getAuthorities().stream()
                .anyMatch(authority -> List.of("ROLE_ARCHITECT", "ROLE_ADMIN").contains(authority.getAuthority()));
    }

    private static ResponseEntity.BodyBuilder documentResponse(ArchitectureEditorService.Document document) {
        if ("GIT_CHECKPOINT".equals(document.source())) {
            return ResponseEntity.ok().header(HttpHeaders.CACHE_CONTROL, "no-store")
                    .eTag(GitHttpPrecondition.etag(document.context().commit()))
                    .header("X-Taxonomy-Source", document.source());
        }
        return response(HttpStatus.OK, document.context()).header("X-Taxonomy-Source", document.source());
    }

    @PostMapping("/api/architecture/editor/versions/recover")
    @ResponseBody
    public ResponseEntity<Void> recoverVersion() throws IOException {
        service.reconcileVersion(resolver.resolveCurrentRepositoryContext());
        return ResponseEntity.noContent().build();
    }

    private static String etag(Context context) { return "\"workspace-revision-" + context.revision() + "\""; }

    private static void requireRevision(Context context, String ifMatch, String ifNoneMatch) {
        if (ifMatch == null) throw new GitHttpPrecondition.PreconditionRequiredException("An exact semantic revision If-Match header is required");
        if (ifNoneMatch != null || !etag(context).equals(ifMatch)) {
            throw new IllegalArgumentException("HTTP precondition must match the semantic workspace revision");
        }
    }

    private static ResponseEntity.BodyBuilder response(HttpStatus status, Context context) {
        return ResponseEntity.status(status).header(HttpHeaders.CACHE_CONTROL, "no-store")
                .header(HttpHeaders.ETAG, etag(context))
                .header("X-Taxonomy-Semantic-Revision", Long.toString(context.revision()));
    }

    @PostMapping("/api/architecture/editor/checkpoints")
    @ResponseBody
    public ResponseEntity<CheckpointAccepted> checkpoint(@RequestBody CreateCheckpointCommand command,
            @RequestHeader(value = HttpHeaders.IF_MATCH, required = false) String ifMatch) throws IOException {
        requireRevision(command.context(), ifMatch, null);
        var result = service.checkpoint(resolver.resolveCurrentRepositoryContext(), command);
        return response(HttpStatus.OK, result.context()).body(result);
    }

    @PostMapping("/api/architecture/editor/checkpoints/resume")
    @ResponseBody
    public ResponseEntity<CheckpointAccepted> resumeCheckpoint() throws IOException {
        var result = service.resumeCheckpoint(resolver.resolveCurrentRepositoryContext());
        return response(HttpStatus.OK, result.context()).body(result);
    }

    @ExceptionHandler(RevisionConflict.class)
    @ResponseBody
    public ResponseEntity<Map<String, Object>> conflict(RevisionConflict error) {
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).body(Map.of(
                "code", "REVISION_MOVED", "detail", "The workspace changed. Refresh, compare and preview again.",
                "expectedRevision", error.expected(), "currentRevision", error.actual()));
    }

    @ExceptionHandler(GitHttpPrecondition.PreconditionRequiredException.class)
    @ResponseBody
    public ResponseEntity<Map<String, String>> missingPrecondition(Exception error) {
        return ResponseEntity.status(428).body(Map.of("code", "PRECONDITION_REQUIRED", "detail", error.getMessage()));
    }

    @ExceptionHandler(CommandProblem.class)
    @ResponseBody
    public ResponseEntity<Map<String, Object>> problem(CommandProblem error) {
        int status = switch (error.code()) {
            case "NOT_FOUND" -> 404;
            case "READ_ONLY" -> 403;
            case "CONTEXT_CHANGED", "UNDO_CONFLICT", "ALREADY_INVERTED", "COMMAND_ID_REUSED", "DEPENDENCIES_EXIST", "CHECKPOINT_PENDING", "CHECKPOINT_CONFLICT", "VERSION_CHANGED" -> 409;
            default -> 422;
        };
        return ResponseEntity.status(status).body(Map.of("code", error.code(), "field", error.field(),
                "detail", error.getMessage(), "dependencies", error.dependencies()));
    }

    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseBody
    public ResponseEntity<Map<String, String>> invalid(IllegalArgumentException error) {
        return ResponseEntity.badRequest().body(Map.of("code", "INVALID_COMMAND", "detail", error.getMessage() == null ? "Invalid command" : error.getMessage()));
    }

    @ExceptionHandler(IOException.class)
    @ResponseBody
    public ResponseEntity<Map<String, String>> unavailable() {
        return ResponseEntity.status(503).body(Map.of("code", "GIT_UNAVAILABLE", "detail", "Git storage is unavailable; refresh before retrying the same command identity"));
    }
}