001/*
002 * Copyright 2022-2026 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package com.soklet;
017
018import org.jspecify.annotations.NonNull;
019import org.jspecify.annotations.Nullable;
020
021import javax.annotation.concurrent.ThreadSafe;
022import java.net.URI;
023import java.util.ArrayList;
024import java.util.HashSet;
025import java.util.List;
026import java.util.Locale;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030
031import static java.util.Objects.requireNonNull;
032
033/**
034 * Immutable, explicitly grouped locale alternatives for one Skills name.
035 *
036 * <p>The key is an opaque application-defined, endpoint-local label, not a wire
037 * skill identifier. Grouping grants no access and does not select a language or
038 * translate file bytes. Group different content versions separately instead of
039 * relying on registration order to choose between same-locale versions.
040 *
041 * @author <a href="https://www.revetkn.com">Mark Allen</a>
042 */
043@ThreadSafe
044public final class McpSkillGroup {
045        @NonNull
046        private final String key;
047        @NonNull
048        private final List<@NonNull McpSkillRegistration> skillRegistrations;
049
050        private McpSkillGroup(@NonNull String key,
051                        @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) {
052                this.key = key;
053                this.skillRegistrations = skillRegistrations;
054        }
055
056        /**
057         * Snapshots ordered alternatives sharing one validated skill name.
058         *
059         * <p>An empty group is permitted. Among its members, registration URIs must be
060         * distinct under {@link URI#equals(Object)} and locales must be unique,
061         * including at most one member with an undeclared locale. The key is preserved
062         * literally; it is neither trimmed nor normalized.
063         *
064         * @param key nonblank application-local group label
065         * @param skillRegistrations alternatives in application order, possibly empty
066         * @return immutable group
067         * @throws NullPointerException if the key, list, or a member is null
068         * @throws IllegalArgumentException if the key is blank, member skill names
069         * differ, or member URIs or locales are duplicated
070         */
071        @NonNull
072        public static McpSkillGroup fromKeyAndSkillRegistrations(@NonNull String key,
073                        @NonNull List<@NonNull McpSkillRegistration> skillRegistrations) {
074                requireNonNull(key, "A Skills group key is required.");
075                requireNonNull(skillRegistrations, "Skills group registrations are required.");
076                if (key.isBlank()) throw invalid();
077                List<McpSkillRegistration> snapshot = new ArrayList<>();
078                Set<URI> uris = new HashSet<>();
079                Set<Optional<Locale>> locales = new HashSet<>();
080                String name = null;
081                for (McpSkillRegistration registration : skillRegistrations) {
082                        requireNonNull(registration, "A Skills group registration is required.");
083                        String memberName = registration.getSkillBundle().getName();
084                        if (name == null) name = memberName;
085                        else if (!name.equals(memberName)) throw invalid();
086                        if (!uris.add(registration.getUri()) || !locales.add(registration.getLocale())) throw invalid();
087                        snapshot.add(registration);
088                }
089                return new McpSkillGroup(key, List.copyOf(snapshot));
090        }
091
092        /** @return exact application-defined group label */
093        @NonNull
094        public String getKey() { return this.key; }
095
096        /** @return immutable alternatives in the supplied order */
097        @NonNull
098        public List<@NonNull McpSkillRegistration> getSkillRegistrations() { return this.skillRegistrations; }
099
100        /** @return whether the exact key and ordered registration values match */
101        @Override
102        public boolean equals(@Nullable Object other) {
103                return this == other || other instanceof McpSkillGroup group
104                                && this.key.equals(group.key) && this.skillRegistrations.equals(group.skillRegistrations);
105        }
106
107        /** @return structural group hash code */
108        @Override
109        public int hashCode() { return Objects.hash(this.key, this.skillRegistrations); }
110
111        /** @return diagnostic rendering without group labels or registrations */
112        @Override
113        @NonNull
114        public String toString() { return "McpSkillGroup[redacted]"; }
115
116        private static @NonNull IllegalArgumentException invalid() {
117                return new IllegalArgumentException("Invalid Skills group.");
118        }
119}