/*
 * This file is part of Dependency-Track.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 * Copyright (c) Steve Springett. All Rights Reserved.
 */
package org.dependencytrack.persistence;

import alpine.common.logging.Logger;
import alpine.event.framework.Event;
import alpine.model.ApiKey;
import alpine.model.Permission;
import alpine.model.Team;
import alpine.model.UserPrincipal;
import alpine.notification.Notification;
import alpine.notification.NotificationLevel;
import alpine.persistence.PaginatedResult;
import alpine.resources.AlpineRequest;
import com.github.packageurl.PackageURL;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.dependencytrack.auth.Permissions;
import org.dependencytrack.event.IndexEvent;
import org.dependencytrack.model.Analysis;
import org.dependencytrack.model.AnalysisComment;
import org.dependencytrack.model.Classifier;
import org.dependencytrack.model.Component;
import org.dependencytrack.model.ConfigPropertyConstants;
import org.dependencytrack.model.FindingAttribution;
import org.dependencytrack.model.Project;
import org.dependencytrack.model.ProjectProperty;
import org.dependencytrack.model.ProjectVersion;
import org.dependencytrack.model.ServiceComponent;
import org.dependencytrack.model.Tag;
import org.dependencytrack.model.Vulnerability;
import org.dependencytrack.notification.NotificationConstants;
import org.dependencytrack.notification.NotificationGroup;
import org.dependencytrack.notification.NotificationScope;
import org.dependencytrack.util.NotificationUtil;
import javax.jdo.FetchPlan;
import javax.jdo.PersistenceManager;
import javax.jdo.Query;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

final class ProjectQueryManager extends QueryManager implements IQueryManager {

    private static final Logger LOGGER = Logger.getLogger(ProjectQueryManager.class);

    /**
     * Constructs a new QueryManager.
     * @param pm a PersistenceManager object
     */
    ProjectQueryManager(final PersistenceManager pm) {
        super(pm);
    }

    /**
     * Constructs a new QueryManager.
     * @param pm a PersistenceManager object
     * @param request an AlpineRequest object
     */
    ProjectQueryManager(final PersistenceManager pm, final AlpineRequest request) {
        super(pm, request);
    }

    /**
     * Returns a list of all projects.
     * @return a List of Projects
     */
    @Override
    public PaginatedResult getProjects(final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) {
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc, version desc");
        }

        var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive);

        if (onlyRoot){
            filterBuilder.excludeChildProjects();
            query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        }

        if (filter != null) {
            final String filterString = ".*" + filter.toLowerCase() + ".*";
            final Tag tag = getTagByName(filter.trim());

            if (tag != null) {
                filterBuilder = filterBuilder.withFuzzyNameOrExactTag(filterString, tag);

            } else {
                filterBuilder = filterBuilder.withFuzzyName(filterString);
            }
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        result = execute(query, params);
        if (includeMetrics) {
            // Populate each Project object in the paginated result with transitive related
            // data to minimize the number of round trips a client needs to make, process, and render.
            for (Project project : result.getList(Project.class)) {
                project.setMetrics(getMostRecentProjectMetrics(project));
            }
        }
        return result;
    }

    /**
     * Returns a list of all projects.
     * @return a List of Projects
     */
    @Override
    public PaginatedResult getProjects(final boolean includeMetrics) {
        return getProjects(includeMetrics, false, false);
    }

    /**
     * Returns a list of all projects.
     * @return a List of Projects
     */
    @Override
    public PaginatedResult getProjects() {
        return getProjects(false);
    }

    /**
     * Returns a list of all projects.
     * This method if designed NOT to provide paginated results.
     * @return a List of Projects
     */
    @Override
    public List<Project> getAllProjects() {
        return getAllProjects(false);
    }

    /**
     * Returns a list of all projects.
     * This method if designed NOT to provide paginated results.
     * @return a List of Projects
     */
    @Override
    public List<Project> getAllProjects(boolean excludeInactive) {
        final Query<Project> query = pm.newQuery(Project.class);
        if (excludeInactive) {
            query.setFilter("active == true || active == null");
        }
        query.setOrdering("id asc");
        return query.executeList();
    }

    /**
     * Returns a list of projects by their name.
     * @param name the name of the Projects (required)
     * @return a List of Project objects
     */
    @Override
    public PaginatedResult getProjects(final String name, final boolean excludeInactive, final boolean onlyRoot) {
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("version desc");
        }

        final var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withName(name);

        if (onlyRoot) {
            filterBuilder.excludeChildProjects();
            query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        return execute(query, params);
    }

    /**
     * Returns a project by its uuid.
     * @param uuid the uuid of the Project (required)
     * @return a Project object, or null if not found
     */
    @Override
    public Project getProject(final String uuid) {
        final Project project = getObjectByUuid(Project.class, uuid, Project.FetchGroup.ALL.name());
        if (project != null) {
            // set Metrics to minimize the number of round trips a client needs to make
            project.setMetrics(getMostRecentProjectMetrics(project));
            // set ProjectVersions to minimize the number of round trips a client needs to make
            project.setVersions(getProjectVersions(project));
        }
        return project;
    }


    /**
     * Returns a project by its name and version.
     * @param name the name of the Project (required)
     * @param version the version of the Project (or null)
     * @return a Project object, or null if not found
     */
    @Override
    public Project getProject(final String name, final String version) {
        final Query<Project> query = pm.newQuery(Project.class);

        final var filterBuilder = new ProjectQueryFilterBuilder()
                .withName(name)
                .withVersion(version);

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        query.setFilter(queryFilter);
        query.setRange(0, 1);
        final Project project = singleResult(query.executeWithMap(params));
        if (project != null) {
            // set Metrics to prevent extra round trip
            project.setMetrics(getMostRecentProjectMetrics(project));
            // set ProjectVersions to prevent extra round trip
            project.setVersions(getProjectVersions(project));
        }
        return project;
    }

    /**
     * Returns a list of projects that are accessible by the specified team.
     * @param team the team the has access to Projects
     * @return a List of Project objects
     */
    @Override
    public PaginatedResult getProjects(final Team team, final boolean excludeInactive, final boolean bypass, final boolean onlyRoot) {
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc, version desc, id asc");
        }

        final var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withTeam(team);

        if (onlyRoot){
            filterBuilder.excludeChildProjects();
            query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, bypass);
        return execute(query, params);
    }

    /**
     * Returns a paginated result of projects by tag.
     * @param tag the tag associated with the Project
     * @return a List of Projects that contain the tag
     */
    @Override
    public PaginatedResult getProjects(final Tag tag, final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) {
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc");
        }

        var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withTag(tag);

        if (onlyRoot){
            filterBuilder.excludeChildProjects();
            query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        }

        if (filter != null) {
            final String filterString = ".*" + filter.toLowerCase() + ".*";
            filterBuilder = filterBuilder.withFuzzyName(filterString);
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        result = execute(query, params);
        if (includeMetrics) {
            // Populate each Project object in the paginated result with transitive related
            // data to minimize the number of round trips a client needs to make, process, and render.
            for (Project project : result.getList(Project.class)) {
                project.setMetrics(getMostRecentProjectMetrics(project));
            }
        }
        return result;
    }

    /**
     * Returns a paginated result of projects by classifier.
     * @param classifier the classifier of the Project
     * @return a List of Projects of the specified classifier
     */
    @Override
    public PaginatedResult getProjects(final Classifier classifier, final boolean includeMetrics, final boolean excludeInactive, final boolean onlyRoot) {
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc");
        }

        final var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withClassifier(classifier);

        if (onlyRoot){
            filterBuilder.excludeChildProjects();
            query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        result = execute(query, params);
        if (includeMetrics) {
            // Populate each Project object in the paginated result with transitive related
            // data to minimize the number of round trips a client needs to make, process, and render.
            for (Project project : result.getList(Project.class)) {
                project.setMetrics(getMostRecentProjectMetrics(project));
            }
        }
        return result;
    }

    /**
     * Returns a paginated result of projects by tag.
     * @param tag the tag associated with the Project
     * @return a List of Projects that contain the tag
     */
    @Override
    public PaginatedResult getProjects(final Tag tag) {
        return getProjects(tag, false, false, false);
    }

    /**
     * Returns a list of Tag objects what have been resolved. It resolved
     * tags by querying the database to retrieve the tag. If the tag does
     * not exist, the tag will be created and returned with other resolved
     * tags.
     * @param tags a List of Tags to resolve
     * @return List of resolved Tags
     */
    private synchronized List<Tag> resolveTags(final List<Tag> tags) {
        if (tags == null) {
            return new ArrayList<>();
        }
        final List<Tag> resolvedTags = new ArrayList<>();
        final List<String> unresolvedTags = new ArrayList<>();
        for (final Tag tag: tags) {
            final String trimmedTag = StringUtils.trimToNull(tag.getName());
            if (trimmedTag != null) {
                final Tag resolvedTag = getTagByName(trimmedTag);
                if (resolvedTag != null) {
                    resolvedTags.add(resolvedTag);
                } else {
                    unresolvedTags.add(trimmedTag);
                }
            }
        }
        resolvedTags.addAll(createTags(unresolvedTags));
        return resolvedTags;
    }

    /**
     * Returns a list of Tag objects by name.
     * @param name the name of the Tag
     * @return a Tag object
     */
    @Override
    public Tag getTagByName(final String name) {
        final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name));
        final Query<Tag> query = pm.newQuery(Tag.class, "name == :name");
        query.setRange(0, 1);
        return singleResult(query.execute(loweredTrimmedTag));
    }

    /**
     * Creates a new Tag object with the specified name.
     * @param name the name of the Tag to create
     * @return the created Tag object
     */
    @Override
    public Tag createTag(final String name) {
        final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name));
        final Tag resolvedTag = getTagByName(loweredTrimmedTag);
        if (resolvedTag != null) {
            return resolvedTag;
        }
        final Tag tag = new Tag();
        tag.setName(loweredTrimmedTag);
        return persist(tag);
    }

    /**
     * Creates one or more Tag objects from the specified name(s).
     * @param names the name(s) of the Tag(s) to create
     * @return the created Tag object(s)
     */
    private List<Tag> createTags(final List<String> names) {
        final List<Tag> newTags = new ArrayList<>();
        for (final String name: names) {
            final String loweredTrimmedTag = StringUtils.lowerCase(StringUtils.trimToNull(name));
            if (getTagByName(loweredTrimmedTag) == null) {
                final Tag tag = new Tag();
                tag.setName(loweredTrimmedTag);
                newTags.add(tag);
            }
        }
        return new ArrayList<>(persist(newTags));
    }

    /**
     * Creates a new Project.
     * @param name the name of the project to create
     * @param description a description of the project
     * @param version the project version
     * @param tags a List of Tags - these will be resolved if necessary
     * @param parent an optional parent Project
     * @param purl an optional Package URL
     * @param active specified if the project is active
     * @param commitIndex specifies if the search index should be committed (an expensive operation)
     * @return the created Project
     */
    @Override
    public Project createProject(String name, String description, String version, List<Tag> tags, Project parent, PackageURL purl, boolean active, boolean commitIndex) {
        final Project project = new Project();
        project.setName(name);
        project.setDescription(description);
        project.setVersion(version);
        if (parent != null ) {
            if (!Boolean.TRUE.equals(parent.isActive())){
                throw new IllegalArgumentException("An inactive Parent cannot be selected as parent");
            }
            project.setParent(parent);
        }
        project.setPurl(purl);
        project.setActive(active);
        final Project result = persist(project);

        final List<Tag> resolvedTags = resolveTags(tags);
        bind(project, resolvedTags);

        Event.dispatch(new IndexEvent(IndexEvent.Action.CREATE, pm.detachCopy(result)));
        Notification.dispatch(new Notification()
                .scope(NotificationScope.PORTFOLIO)
                .group(NotificationGroup.PROJECT_CREATED)
                .title(NotificationConstants.Title.PROJECT_CREATED)
                .level(NotificationLevel.INFORMATIONAL)
                .content(result.getName() + " was created")
                .subject(NotificationUtil.toJson(pm.detachCopy(result)))
        );
        commitSearchIndex(commitIndex, Project.class);
        return result;
    }

    /**
     * Creates a new Project.
     * @param project the project to create
     * @param tags a List of Tags - these will be resolved if necessary
     * @param commitIndex specifies if the search index should be committed (an expensive operation)
     * @return the created Project
     */
    @Override
    public Project createProject(final Project project, List<Tag> tags, boolean commitIndex) {
        if (project.getParent() != null && !Boolean.TRUE.equals(project.getParent().isActive())){
            throw new IllegalArgumentException("An inactive Parent cannot be selected as parent");
        }
        final Project result = persist(project);
        final List<Tag> resolvedTags = resolveTags(tags);
        bind(project, resolvedTags);

        Event.dispatch(new IndexEvent(IndexEvent.Action.CREATE, pm.detachCopy(result)));
        commitSearchIndex(commitIndex, Project.class);
        return result;
    }

    /**
     * Updates an existing Project.
     * @param uuid the uuid of the project to update
     * @param name the name of the project
     * @param description a description of the project
     * @param version the project version
     * @param tags a List of Tags - these will be resolved if necessary
     * @param purl an optional Package URL
     * @param active specified if the project is active
     * @param commitIndex specifies if the search index should be committed (an expensive operation)
     * @return the updated Project
     */
    @Override
    public Project updateProject(UUID uuid, String name, String description, String version, List<Tag> tags, PackageURL purl, boolean active, boolean commitIndex) {
        final Project project = getObjectByUuid(Project.class, uuid);
        project.setName(name);
        project.setDescription(description);
        project.setVersion(version);
        project.setPurl(purl);

        if (!active && Boolean.TRUE.equals(project.isActive()) && hasActiveChild(project)){
            throw new IllegalArgumentException("Project cannot be set to inactive, if active children are present.");
        }
        project.setActive(active);

        final List<Tag> resolvedTags = resolveTags(tags);
        bind(project, resolvedTags);

        final Project result = persist(project);
        Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, pm.detachCopy(result)));
        commitSearchIndex(commitIndex, Project.class);
        return result;
    }

    /**
     * Updates an existing Project.
     * @param transientProject the project to update
     * @param commitIndex specifies if the search index should be committed (an expensive operation)
     * @return the updated Project
     */
    @Override
    public Project updateProject(Project transientProject, boolean commitIndex) {
        final Project project = getObjectByUuid(Project.class, transientProject.getUuid());
        project.setAuthor(transientProject.getAuthor());
        project.setPublisher(transientProject.getPublisher());
        project.setGroup(transientProject.getGroup());
        project.setName(transientProject.getName());
        project.setDescription(transientProject.getDescription());
        project.setVersion(transientProject.getVersion());
        project.setClassifier(transientProject.getClassifier());
        project.setCpe(transientProject.getCpe());
        project.setPurl(transientProject.getPurl());
        project.setSwidTagId(transientProject.getSwidTagId());
        project.setExternalReferences(transientProject.getExternalReferences());

        if (Boolean.TRUE.equals(project.isActive()) && !Boolean.TRUE.equals(transientProject.isActive()) && hasActiveChild(project)){
            throw new IllegalArgumentException("Project cannot be set to inactive if active children are present.");
        }
        project.setActive(transientProject.isActive());

        if (transientProject.getParent() != null && transientProject.getParent().getUuid() != null) {
            if (project.getUuid().equals(transientProject.getParent().getUuid())){
                throw new IllegalArgumentException("A project cannot select itself as a parent");
            }
            Project parent = getObjectByUuid(Project.class, transientProject.getParent().getUuid());
            if (!Boolean.TRUE.equals(parent.isActive())){
                throw new IllegalArgumentException("An inactive project cannot be selected as a parent");
            } else if (isChildOf(parent, transientProject.getUuid())){
                throw new IllegalArgumentException("The new parent project cannot be a child of the current project.");
            } else {
                project.setParent(parent);
            }
            project.setParent(parent);
        }else {
            project.setParent(null);
        }

        final List<Tag> resolvedTags = resolveTags(transientProject.getTags());
        bind(project, resolvedTags);

        final Project result = persist(project);
        Event.dispatch(new IndexEvent(IndexEvent.Action.UPDATE, pm.detachCopy(result)));
        commitSearchIndex(commitIndex, Project.class);
        return result;
    }

    @Override
    public Project clone(UUID from, String newVersion, boolean includeTags, boolean includeProperties,
                         boolean includeComponents, boolean includeServices, boolean includeAuditHistory,
                         boolean includeACL) {
        final Project source = getObjectByUuid(Project.class, from, Project.FetchGroup.ALL.name());
        if (source == null) {
            LOGGER.warn("Project with UUID %s was supposed to be cloned, but it does not exist anymore".formatted(from));
            return null;
        }
        if (doesProjectExist(source.getName(), newVersion)) {
            // Project cloning is an asynchronous process. When receiving the clone request, we already perform
            // this check. It is possible though that a project with the new version is created synchronously
            // between the clone event being dispatched, and it being processed.
            LOGGER.warn("Project %s was supposed to be cloned to version %s, but that version already exists"
                    .formatted(source, newVersion));
            return null;
        }
        Project project = new Project();
        project.setAuthor(source.getAuthor());
        project.setPublisher(source.getPublisher());
        project.setGroup(source.getGroup());
        project.setName(source.getName());
        project.setDescription(source.getDescription());
        project.setVersion(newVersion);
        project.setClassifier(source.getClassifier());
        project.setActive(source.isActive());
        project.setCpe(source.getCpe());
        project.setPurl(source.getPurl());
        project.setSwidTagId(source.getSwidTagId());
        if (includeComponents && includeServices) {
            project.setDirectDependencies(source.getDirectDependencies());
        }
        project.setParent(source.getParent());
        project = persist(project);

        if (includeTags) {
            for (final Tag tag: source.getTags()) {
                tag.getProjects().add(project);
                persist(tag);
            }
        }

        if (includeProperties && source.getProperties() != null) {
            for (final ProjectProperty sourceProperty: source.getProperties()) {
                final ProjectProperty property = new ProjectProperty();
                property.setProject(project);
                property.setPropertyType(sourceProperty.getPropertyType());
                property.setGroupName(sourceProperty.getGroupName());
                property.setPropertyName(sourceProperty.getPropertyName());
                property.setPropertyValue(sourceProperty.getPropertyValue());
                property.setDescription(sourceProperty.getDescription());
                persist(property);
            }
        }

        final Map<Long, Component> clonedComponents = new HashMap<>();
        if (includeComponents) {
            final List<Component> sourceComponents = getAllComponents(source);
            if (sourceComponents != null) {
                for (final Component sourceComponent: sourceComponents) {
                    final Component clonedComponent = cloneComponent(sourceComponent, project, false);
                    // Add vulnerabilties and finding attribution from the source component to the cloned component
                    for (Vulnerability vuln: sourceComponent.getVulnerabilities()) {
                        final FindingAttribution sourceAttribution = this.getFindingAttribution(vuln, sourceComponent);
                        this.addVulnerability(vuln, clonedComponent, sourceAttribution.getAnalyzerIdentity(), sourceAttribution.getAlternateIdentifier(), sourceAttribution.getReferenceUrl());
                    }
                    clonedComponents.put(sourceComponent.getId(), clonedComponent);
                }
            }
        }

        if (includeServices) {
            final List<ServiceComponent> sourceServices = getAllServiceComponents(source);
            if (sourceServices != null) {
                for (final ServiceComponent sourceService : sourceServices) {
                    cloneServiceComponent(sourceService, project, false);
                }
            }
        }

        if (includeAuditHistory && includeComponents) {
            final List<Analysis> analyses = super.getAnalyses(source);
            if (analyses != null) {
                for (final Analysis sourceAnalysis: analyses) {
                    Analysis analysis = new Analysis();
                    analysis.setAnalysisState(sourceAnalysis.getAnalysisState());
                    final Component clonedComponent = clonedComponents.get(sourceAnalysis.getComponent().getId());
                    if (clonedComponent == null) {
                        break;
                    }
                    analysis.setComponent(clonedComponent);
                    analysis.setVulnerability(sourceAnalysis.getVulnerability());
                    analysis.setSuppressed(sourceAnalysis.isSuppressed());
                    analysis.setAnalysisResponse(sourceAnalysis.getAnalysisResponse());
                    analysis.setAnalysisJustification(sourceAnalysis.getAnalysisJustification());
                    analysis.setAnalysisState(sourceAnalysis.getAnalysisState());
                    analysis.setAnalysisDetails(sourceAnalysis.getAnalysisDetails());
                    analysis = persist(analysis);
                    if (sourceAnalysis.getAnalysisComments() != null) {
                        for (final AnalysisComment sourceComment: sourceAnalysis.getAnalysisComments()) {
                            final AnalysisComment analysisComment = new AnalysisComment();
                            analysisComment.setAnalysis(analysis);
                            analysisComment.setTimestamp(sourceComment.getTimestamp());
                            analysisComment.setComment(sourceComment.getComment());
                            analysisComment.setCommenter(sourceComment.getCommenter());
                            persist(analysisComment);
                        }
                    }
                }
            }
        }

        if (includeACL) {
            List<Team> accessTeams = source.getAccessTeams();
            if (!CollectionUtils.isEmpty(accessTeams)) {
                project.setAccessTeams(new ArrayList<>(accessTeams));
            }
        }

        project = getObjectById(Project.class, project.getId());
        Event.dispatch(new IndexEvent(IndexEvent.Action.CREATE, pm.detachCopy(project)));
        commitSearchIndex(true, Project.class);
        return project;
    }

    /**
     * Deletes a Project and all objects dependant on the project.
     * @param project the Project to delete
     * @param commitIndex specifies if the search index should be committed (an expensive operation)
     */
    @Override
    public void recursivelyDelete(final Project project, final boolean commitIndex) {
        if (project.getChildren() != null) {
            for (final Project child: project.getChildren()) {
                recursivelyDelete(child, false);
            }
        }
        pm.getFetchPlan().setDetachmentOptions(FetchPlan.DETACH_LOAD_FIELDS);
        final Project result = pm.getObjectById(Project.class, project.getId());
        Event.dispatch(new IndexEvent(IndexEvent.Action.DELETE, pm.detachCopy(result)));
        commitSearchIndex(commitIndex, Project.class);

        deleteAnalysisTrail(project);
        deleteViolationAnalysisTrail(project);
        deleteMetrics(project);
        deleteFindingAttributions(project);
        deletePolicyViolations(project);
        deleteComponents(project);

        for (final ServiceComponent s: getAllServiceComponents(project)) {
            recursivelyDelete(s, false);
        }
        deleteBoms(project);
        deleteVexs(project);
        removeProjectFromNotificationRules(project);
        removeProjectFromPolicies(project);
        delete(project.getProperties());
        delete(getAllBoms(project));
        delete(project.getChildren());
        delete(project);
    }

    /**
     * Creates a key/value pair (ProjectProperty) for the specified Project.
     * @param project the Project to create the property for
     * @param groupName the group name of the property
     * @param propertyName the name of the property
     * @param propertyValue the value of the property
     * @param propertyType the type of property
     * @param description a description of the property
     * @return the created ProjectProperty object
     */
    @Override
    public ProjectProperty createProjectProperty(final Project project, final String groupName, final String propertyName,
                                                 final String propertyValue, final ProjectProperty.PropertyType propertyType,
                                                 final String description) {
        final ProjectProperty property = new ProjectProperty();
        property.setProject(project);
        property.setGroupName(groupName);
        property.setPropertyName(propertyName);
        property.setPropertyValue(propertyValue);
        property.setPropertyType(propertyType);
        property.setDescription(description);
        return persist(property);
    }

    /**
     * Returns a ProjectProperty with the specified groupName and propertyName.
     * @param project the project the property belongs to
     * @param groupName the group name of the config property
     * @param propertyName the name of the property
     * @return a ProjectProperty object
     */
    @Override
    public ProjectProperty getProjectProperty(final Project project, final String groupName, final String propertyName) {
        final Query<ProjectProperty> query = this.pm.newQuery(ProjectProperty.class, "project == :project && groupName == :groupName && propertyName == :propertyName");
        query.setRange(0, 1);
        return singleResult(query.execute(project, groupName, propertyName));
    }

    /**
     * Returns a List of ProjectProperty's for the specified project.
     * @param project the project the property belongs to
     * @return a List ProjectProperty objects
     */
    @Override
    @SuppressWarnings("unchecked")
    public List<ProjectProperty> getProjectProperties(final Project project) {
        final Query<ProjectProperty> query = this.pm.newQuery(ProjectProperty.class, "project == :project");
        query.setOrdering("groupName asc, propertyName asc");
        return (List<ProjectProperty>)query.execute(project);
    }

    /**
     * Binds the two objects together in a corresponding join table.
     * @param project a Project object
     * @param tags a List of Tag objects
     */
    @SuppressWarnings("unchecked")
    @Override
    public void bind(Project project, List<Tag> tags) {
        final Query<Tag> query = pm.newQuery(Tag.class, "projects.contains(:project)");
        final List<Tag> currentProjectTags = (List<Tag>)query.execute(project);
        pm.currentTransaction().begin();
        for (final Tag tag: currentProjectTags) {
            if (!tags.contains(tag)) {
                tag.getProjects().remove(project);
            }
        }
        project.setTags(tags);
        for (final Tag tag: tags) {
            final List<Project> projects = tag.getProjects();
            if (!projects.contains(project)) {
                projects.add(project);
            }
        }
        pm.currentTransaction().commit();
    }

    /**
     * Updates the last time a bom was imported.
     * @param date the date of the last bom import
     * @param bomFormat the format and version of the bom format
     * @return the updated Project
     */
    @Override
    public Project updateLastBomImport(Project p, Date date, String bomFormat) {
        final Project project = getObjectById(Project.class, p.getId());
        project.setLastBomImport(date);
        project.setLastBomImportFormat(bomFormat);
        return persist(project);
    }

    @Override
    public boolean hasAccess(final Principal principal, final Project project) {
        if (isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED)) {
            if (principal instanceof final UserPrincipal userPrincipal) {
                if (super.hasAccessManagementPermission(userPrincipal)) {
                    return true;
                }
                if (userPrincipal.getTeams() != null) {
                    for (final Team userInTeam : userPrincipal.getTeams()) {
                        for (final Team accessTeam : project.getAccessTeams()) {
                            if (userInTeam.getId() == accessTeam.getId()) {
                                return true;
                            }
                        }
                    }
                }
            } else if (principal instanceof final ApiKey apiKey ){
                if (super.hasAccessManagementPermission(apiKey)) {
                    return true;
                }
                if (apiKey.getTeams() != null) {
                    for (final Team userInTeam : apiKey.getTeams()) {
                        for (final Team accessTeam : project.getAccessTeams()) {
                            if (userInTeam.getId() == accessTeam.getId()) {
                                return true;
                            }
                        }
                    }
                }
            } else if (principal == null) {
                // This is a system request being made (e.g. MetricsUpdateTask, etc) where there isn't a principal
                return true;
            }
            return false;
        } else {
            return true;
        }
    }

    /**
     * A similar method exists in ComponentQueryManager
     */
    private void preprocessACLs(final Query<Project> query, final String inputFilter, final Map<String, Object> params, final boolean bypass) {
        if (super.principal != null && isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && !bypass) {
            final List<Team> teams;
            if (super.principal instanceof final UserPrincipal userPrincipal) {
                teams = userPrincipal.getTeams();
                if (super.hasAccessManagementPermission(userPrincipal)) {
                    query.setFilter(inputFilter);
                    return;
                }
            } else {
                final ApiKey apiKey = ((ApiKey) super.principal);
                teams = apiKey.getTeams();
                if (super.hasAccessManagementPermission(apiKey)) {
                    query.setFilter(inputFilter);
                    return;
                }
            }
            if (teams != null && teams.size() > 0) {
                final StringBuilder sb = new StringBuilder();
                for (int i = 0, teamsSize = teams.size(); i < teamsSize; i++) {
                    final Team team = super.getObjectById(Team.class, teams.get(i).getId());
                    sb.append(" accessTeams.contains(:team").append(i).append(") ");
                    params.put("team" + i, team);
                    if (i < teamsSize-1) {
                        sb.append(" || ");
                    }
                }
                if (inputFilter != null && !inputFilter.isBlank()) {
                    query.setFilter(inputFilter + " && (" + sb.toString() + ")");
                } else {
                    query.setFilter(sb.toString());
                }
            }
        } else if (StringUtils.trimToNull(inputFilter) != null) {
            query.setFilter(inputFilter);
        }
    }

    /**
     * Updates a Project ACL to add the principals Team to the AccessTeams
     * This only happens if Portfolio Access Control is enabled and the @param principal is an ApyKey
     * For a UserPrincipal we don't know which Team(s) to add to the ACL,
     * See https://github.com/DependencyTrack/dependency-track/issues/1435
     * @param project
     * @param principal
     * @return True if ACL was updated
     */
    @Override
    public boolean updateNewProjectACL(Project project, Principal principal) {
        if (isEnabled(ConfigPropertyConstants.ACCESS_MANAGEMENT_ACL_ENABLED) && principal instanceof ApiKey apiKey) {
            final var apiTeam = apiKey.getTeams().stream().findFirst();
            if (apiTeam.isPresent()) {
                LOGGER.debug("adding Team to ACL of newly created project");
                final Team team = getObjectByUuid(Team.class, apiTeam.get().getUuid());
                project.addAccessTeam(team);
                persist(project);
                return true;
            } else {
                LOGGER.warn("API Key without a Team, unable to assign team ACL to project.");
            }
        }
        return false;
    }

    @Override
    public boolean hasAccessManagementPermission(final UserPrincipal userPrincipal) {
        for (Permission permission: getEffectivePermissions(userPrincipal)) {
            if (Permissions.ACCESS_MANAGEMENT.name().equals(permission.getName())) {
                return true;
            }
        }
        return false;
    }

    @Override
    public boolean hasAccessManagementPermission(final ApiKey apiKey) {
        return hasPermission(apiKey, Permissions.ACCESS_MANAGEMENT.name());
    }


    @Override
    public PaginatedResult getChildrenProjects(final UUID uuid, final boolean includeMetrics, final boolean excludeInactive) {
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc, version desc");
        }

        var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withParent(uuid);

        if (filter != null) {
            final String filterString = ".*" + filter.toLowerCase() + ".*";
            final Tag tag = getTagByName(filter.trim());

            if (tag != null) {
                filterBuilder = filterBuilder.withFuzzyNameOrExactTag(filterString, tag);

            } else {
                filterBuilder = filterBuilder.withFuzzyName(filterString);
            }
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        result = execute(query, params);
        if (includeMetrics) {
            // Populate each Project object in the paginated result with transitive related
            // data to minimize the number of round trips a client needs to make, process, and render.
            for (Project project : result.getList(Project.class)) {
                project.setMetrics(getMostRecentProjectMetrics(project));
            }
        }
        return result;
    }

    @Override
    public PaginatedResult getChildrenProjects(final Classifier classifier, final UUID uuid, final boolean includeMetrics, final boolean excludeInactive) {
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc");
        }

        final var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withParent(uuid)
                .withClassifier(classifier);

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        query.getFetchPlan().addGroup(Project.FetchGroup.ALL.name());
        result = execute(query, params);
        if (includeMetrics) {
            // Populate each Project object in the paginated result with transitive related
            // data to minimize the number of round trips a client needs to make, process, and render.
            for (Project project : result.getList(Project.class)) {
                project.setMetrics(getMostRecentProjectMetrics(project));
            }
        }
        return result;
    }

    @Override
    public PaginatedResult getChildrenProjects(final Tag tag, final UUID uuid, final boolean includeMetrics, final boolean excludeInactive) {
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc");
        }

        var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withParent(uuid)
                .withTag(tag);

        if (filter != null) {
            final String filterString = ".*" + filter.toLowerCase() + ".*";
            filterBuilder = filterBuilder.withFuzzyName(filterString);
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        result = execute(query, params);
        if (includeMetrics) {
            // Populate each Project object in the paginated result with transitive related
            // data to minimize the number of round trips a client needs to make, process, and render.
            for (Project project : result.getList(Project.class)) {
                project.setMetrics(getMostRecentProjectMetrics(project));
            }
        }
        return result;
    }

    @Override
    public PaginatedResult getProjectsWithoutDescendantsOf(final boolean exludeInactive, final Project project){
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc, version desc");
        }

        var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(exludeInactive);

        if (filter != null) {
            final String filterString = ".*" + filter.toLowerCase() + ".*";
            final Tag tag = getTagByName(filter.trim());

            if (tag != null) {
                filterBuilder = filterBuilder.withFuzzyNameOrExactTag(filterString, tag);

            } else {
                filterBuilder = filterBuilder.withFuzzyName(filterString);
            }
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        result = execute(query, params);

        result.setObjects(result.getList(Project.class).stream().filter(p -> !isChildOf(p, project.getUuid()) && !p.getUuid().equals(project.getUuid())).toList());
        result.setTotal(result.getObjects().size());

        return result;
    }

    @Override
    public PaginatedResult getProjectsWithoutDescendantsOf(final String name, final boolean excludeInactive, Project project){
        final PaginatedResult result;
        final Query<Project> query = pm.newQuery(Project.class);
        if (orderBy == null) {
            query.setOrdering("name asc, version desc");
        }

        var filterBuilder = new ProjectQueryFilterBuilder()
                .excludeInactive(excludeInactive)
                .withName(name);

        if (filter != null) {
            final String filterString = ".*" + filter.toLowerCase() + ".*";
            final Tag tag = getTagByName(filter.trim());

            if (tag != null) {
                filterBuilder = filterBuilder.withFuzzyNameOrExactTag(filterString, tag);

            } else {
                filterBuilder = filterBuilder.withFuzzyName(filterString);
            }
        }

        final String queryFilter = filterBuilder.buildFilter();
        final Map<String, Object> params = filterBuilder.getParams();

        preprocessACLs(query, queryFilter, params, false);
        result = execute(query, params);

        result.setObjects(result.getList(Project.class).stream().filter(p -> !isChildOf(p, project.getUuid()) && !p.getUuid().equals(project.getUuid())).toList());
        result.setTotal(result.getObjects().size());

        return result;
    }

    /**
     * Check whether a {@link Project} with a given {@code name} and {@code version} exists.
     *
     * @param name    Name of the {@link Project} to check for
     * @param version Version of the {@link Project} to check for
     * @return {@code true} when a matching {@link Project} exists, otherwise {@code false}
     * @since 4.9.0
     */
    @Override
    public boolean doesProjectExist(final String name, final String version) {
        final Query<Project> query = pm.newQuery(Project.class);
        if (version != null) {
            query.setFilter("name == :name && version == :version");
            query.setNamedParameters(Map.of(
                    "name", name,
                    "version", version
            ));
        } else {
            // Version is optional for projects, but using null
            // for parameter values bypasses the query compilation cache.
            // https://github.com/DependencyTrack/dependency-track/issues/2540
            query.setFilter("name == :name && version == null");
            query.setNamedParameters(Map.of(
                    "name", name
            ));
        }
        query.setResult("count(this)");
        try {
            return query.executeResultUnique(Long.class) > 0;
        } finally {
            query.closeAll();
        }
    }

    private static boolean isChildOf(Project project, UUID uuid) {
        boolean isChild = false;
        if (project.getParent() != null){
            if (project.getParent().getUuid().equals(uuid)){
                return true;
            } else {
                isChild = isChildOf(project.getParent(), uuid);
            }
        }
        return isChild;
    }

    private static boolean hasActiveChild(Project project) {
        boolean hasActiveChild = false;
        if (project.getChildren() != null){
            for (Project child: project.getChildren()) {
                if (Boolean.TRUE.equals(child.isActive()) || hasActiveChild) {
                    return true;
                } else {
                    hasActiveChild = hasActiveChild(child);
                }
            }
        }
        return hasActiveChild;
    }

    private List<ProjectVersion> getProjectVersions(Project project) {
        final Query<Project> query = pm.newQuery(Project.class);
        query.setFilter("name == :name");
        query.setParameters(project.getName());
        query.setResult("uuid, version");
        return query.executeResultList(ProjectVersion.class);
    }
}
