/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements. See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.
 */

package org.apache.kafka.tools;

import org.apache.kafka.clients.admin.Admin;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.Config;
import org.apache.kafka.clients.admin.ConfigEntry;
import org.apache.kafka.clients.admin.CreatePartitionsOptions;
import org.apache.kafka.clients.admin.CreateTopicsOptions;
import org.apache.kafka.clients.admin.CreateTopicsResult;
import org.apache.kafka.clients.admin.DeleteTopicsOptions;
import org.apache.kafka.clients.admin.DescribeTopicsOptions;
import org.apache.kafka.clients.admin.ListTopicsOptions;
import org.apache.kafka.clients.admin.ListTopicsResult;
import org.apache.kafka.clients.admin.NewPartitions;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.admin.PartitionReassignment;
import org.apache.kafka.clients.admin.TopicListing;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.TopicCollection;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.TopicPartitionInfo;
import org.apache.kafka.common.Uuid;
import org.apache.kafka.common.config.ConfigResource;
import org.apache.kafka.common.config.TopicConfig;
import org.apache.kafka.common.errors.ClusterAuthorizationException;
import org.apache.kafka.common.errors.TopicExistsException;
import org.apache.kafka.common.errors.UnsupportedVersionException;
import org.apache.kafka.common.internals.Topic;
import org.apache.kafka.common.utils.Exit;
import org.apache.kafka.common.utils.Utils;
import org.apache.kafka.server.util.CommandDefaultOptions;
import org.apache.kafka.server.util.CommandLineUtils;
import org.apache.kafka.storage.internals.log.LogConfig;
import org.apache.kafka.tools.filter.TopicFilter.IncludeList;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import joptsimple.ArgumentAcceptingOptionSpec;
import joptsimple.OptionSpec;
import joptsimple.OptionSpecBuilder;

public abstract class TopicCommand {
    private static final Logger LOG = LoggerFactory.getLogger(TopicCommand.class);

    public static void main(String... args) {
        Exit.exit(mainNoExit(args));
    }

    private static int mainNoExit(String... args) {
        try {
            execute(args);
            return 0;
        } catch (Throwable e) {
            System.err.println(e.getMessage());
            System.err.println(Utils.stackTrace(e));
            return 1;
        }
    }

    static void execute(String... args) throws Exception {
        TopicCommandOptions opts = new TopicCommandOptions(args);
        TopicService topicService = new TopicService(opts.commandConfig(), opts.bootstrapServer());
        int exitCode = 0;
        try {
            if (opts.hasCreateOption()) {
                topicService.createTopic(opts);
            } else if (opts.hasAlterOption()) {
                topicService.alterTopic(opts);
            } else if (opts.hasListOption()) {
                topicService.listTopics(opts);
            } else if (opts.hasDescribeOption()) {
                topicService.describeTopic(opts);
            } else if (opts.hasDeleteOption()) {
                topicService.deleteTopic(opts);
            }
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            printException(Objects.requireNonNullElse(cause, e));
            exitCode = 1;
        } catch (Throwable e) {
            printException(e);
            exitCode = 1;
        } finally {
            topicService.close();
            Exit.exit(exitCode);
        }
    }

    private static void printException(Throwable e) {
        System.out.println("Error while executing topic command : " + e.getMessage());
        LOG.error(Utils.stackTrace(e));
    }

    static Map<Integer, List<Integer>> parseReplicaAssignment(String replicaAssignmentList) {
        String[] partitionList = replicaAssignmentList.split(",");
        Map<Integer, List<Integer>> ret = new LinkedHashMap<>();
        for (int i = 0; i < partitionList.length; i++) {
            List<Integer> brokerList = Arrays.stream(partitionList[i].split(":"))
                .map(String::trim)
                .mapToInt(Integer::parseInt)
                .boxed()
                .collect(Collectors.toList());
            Collection<Integer> duplicateBrokers = ToolsUtils.duplicates(brokerList);
            if (!duplicateBrokers.isEmpty()) {
                throw new AdminCommandFailedException("Partition replica lists may not contain duplicate entries: " +
                    duplicateBrokers.stream()
                        .map(Object::toString)
                        .collect(Collectors.joining(","))
                );
            }
            ret.put(i, brokerList);
            if (ret.get(i).size() != ret.get(0).size()) {
                throw new AdminOperationException("Partition " + i + " has different replication factor: " + brokerList);
            }
        }
        return ret;
    }

    private static Map<String, String> parseTopicConfigsToBeAdded(TopicCommandOptions opts) {
        List<List<String>> configsToBeAdded = opts.topicConfig().orElse(List.of())
            .stream()
            .map(s -> List.of(s.split("\\s*=\\s*")))
            .toList();

        if (!configsToBeAdded.stream().allMatch(config -> config.size() == 2)) {
            throw new IllegalArgumentException("requirement failed: Invalid topic config: all configs to be added must be in the format \"key=val\".");
        }

        Map<String, String> props = new HashMap<>();
        configsToBeAdded.forEach(pair -> props.put(pair.get(0).trim(), pair.get(1).trim()));
        LogConfig.validate(props);
        return props;
    }

    // It is possible for a reassignment to complete between the time we have fetched its state and the time
    // we fetch partition metadata. In this case, we ignore the reassignment when determining replication factor.
    public static boolean isReassignmentInProgress(TopicPartitionInfo tpi, PartitionReassignment ra) {
        // Reassignment is still in progress as long as the removing and adding replicas are still present
        Set<Integer> allReplicaIds = tpi.replicas().stream().map(Node::id).collect(Collectors.toSet());
        Set<Integer> changingReplicaIds = new HashSet<>();
        if (ra != null) {
            changingReplicaIds.addAll(ra.removingReplicas());
            changingReplicaIds.addAll(ra.addingReplicas());
        }
        return allReplicaIds.stream().anyMatch(changingReplicaIds::contains);

    }

    private static int getReplicationFactor(TopicPartitionInfo tpi, PartitionReassignment reassignment) {
        return isReassignmentInProgress(tpi, reassignment) ?
            reassignment.replicas().size() - reassignment.addingReplicas().size() :
            tpi.replicas().size();
    }

    /**
     * ensures topic existence and throws exception if topic doesn't exist
     *
     * @param foundTopics        Topics that were found to match the requested topic name.
     * @param requestedTopic     Name of the topic that was requested.
     * @param requireTopicExists Indicates if the topic needs to exist for the operation to be successful.
     *                           If set to true, the command will throw an exception if the topic with the
     *                           requested name does not exist.
     */
    private static void ensureTopicExists(List<String> foundTopics, Optional<String> requestedTopic, boolean requireTopicExists) {
        // If no topic name was mentioned, do not need to throw exception.
        if (requestedTopic.isPresent() && !requestedTopic.get().isEmpty() && requireTopicExists && foundTopics.isEmpty()) {
            // If given topic doesn't exist then throw exception
            throw new IllegalArgumentException(String.format("Topic '%s' does not exist as expected", requestedTopic.get()));
        }
    }

    private static List<String> doGetTopics(List<String> allTopics, Optional<String> topicIncludeList, boolean excludeInternalTopics) {
        if (topicIncludeList.isPresent()) {
            IncludeList topicsFilter = new IncludeList(topicIncludeList.get());
            return allTopics.stream()
                .filter(topic -> topicsFilter.isTopicAllowed(topic, excludeInternalTopics))
                .collect(Collectors.toList());
        } else {
            return allTopics.stream()
                .filter(topic -> !(Topic.isInternal(topic) && excludeInternalTopics))
                .collect(Collectors.toList());
        }
    }

    /**
     * ensures topic existence and throws exception if topic doesn't exist
     *
     * @param foundTopicIds        Topics that were found to match the requested topic id.
     * @param requestedTopicId     Id of the topic that was requested.
     * @param requireTopicIdExists Indicates if the topic needs to exist for the operation to be successful.
     *                             If set to true, the command will throw an exception if the topic with the
     *                             requested id does not exist.
     */
    private static void ensureTopicIdExists(List<Uuid> foundTopicIds, Uuid requestedTopicId, boolean requireTopicIdExists) {
        // If no topic id was mentioned, do not need to throw exception.
        if (requestedTopicId != null && requireTopicIdExists && foundTopicIds.isEmpty()) {
            // If given topicId doesn't exist then throw exception
            throw new IllegalArgumentException(String.format("TopicId '%s' does not exist as expected", requestedTopicId));
        }
    }

    static class CommandTopicPartition {
        private final String name;
        private final Optional<Integer> partitions;
        private final Optional<Integer> replicationFactor;
        private final Map<Integer, List<Integer>> replicaAssignment;
        private final Map<String, String> configsToAdd;

        private final TopicCommandOptions opts;

        CommandTopicPartition(TopicCommandOptions options) {
            opts = options;
            name = options.topic().get();
            partitions = options.partitions();
            replicationFactor = options.replicationFactor();
            replicaAssignment = options.replicaAssignment().orElse(Map.of());
            configsToAdd = parseTopicConfigsToBeAdded(options);
        }

        boolean hasReplicaAssignment() {
            return !replicaAssignment.isEmpty();
        }

        boolean ifTopicDoesntExist() {
            return opts.ifNotExists();
        }
    }

    static class TopicDescription {
        private final String topic;
        private final Uuid topicId;
        private final int numPartitions;
        private final int replicationFactor;
        private final Config config;

        TopicDescription(String topic, Uuid topicId, int numPartitions, int replicationFactor, Config config) {
            this.topic = topic;
            this.topicId = topicId;
            this.numPartitions = numPartitions;
            this.replicationFactor = replicationFactor;
            this.config = config;
        }

        void printDescription() {
            String configsAsString = config.entries().stream()
                .filter(config -> !config.isDefault())
                .map(ce -> ce.name() + "=" + ce.value())
                .collect(Collectors.joining(","));
            System.out.print("Topic: " +  topic);
            if (!topicId.equals(Uuid.ZERO_UUID))
                System.out.print("\tTopicId: " + topicId);
            System.out.print("\tPartitionCount: " + numPartitions);
            System.out.print("\tReplicationFactor: " + replicationFactor);
            System.out.print("\tConfigs: " + configsAsString);
            System.out.println();
        }
    }

    static class PartitionDescription {
        private final String topic;
        private final TopicPartitionInfo info;
        private final Config config;
        private final PartitionReassignment reassignment;

        PartitionDescription(String topic,
                             TopicPartitionInfo info,
                             Config config,
                             PartitionReassignment reassignment) {
            this.topic = topic;
            this.info = info;
            this.config = config;
            this.reassignment = reassignment;
        }

        int minIsrCount() {
            return Integer.parseInt(config.get(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG).value());
        }

        boolean isUnderReplicated() {
            return getReplicationFactor(info, reassignment) - info.isr().size() > 0;
        }

        boolean hasLeader() {
            return info.leader() != null;
        }

        boolean isUnderMinIsr() {
            return !hasLeader() || info.isr().size() < minIsrCount();
        }

        boolean isAtMinIsrPartitions() {
            return minIsrCount() == info.isr().size();
        }

        boolean hasUnavailablePartitions(Set<Integer> liveBrokers) {
            return !hasLeader() || !liveBrokers.contains(info.leader().id());
        }

        void printDescription() {
            System.out.print("\tTopic: " + topic);
            System.out.print("\tPartition: " + info.partition());
            System.out.print("\tLeader: " + (hasLeader() ? info.leader().id() : "none"));
            System.out.print("\tReplicas: " + info.replicas().stream()
                .map(node -> Integer.toString(node.id()))
                .collect(Collectors.joining(",")));
            System.out.print("\tIsr: " + info.isr().stream()
                .map(node -> Integer.toString(node.id()))
                .collect(Collectors.joining(",")));
            if (reassignment != null) {
                System.out.print("\tAdding Replicas: " + reassignment.addingReplicas().stream()
                    .map(Object::toString)
                    .collect(Collectors.joining(",")));
                System.out.print("\tRemoving Replicas: " + reassignment.removingReplicas().stream()
                    .map(Object::toString)
                    .collect(Collectors.joining(",")));
            }

            if (info.elr() != null) {
                System.out.print("\tElr: " + info.elr().stream()
                    .map(node -> Integer.toString(node.id()))
                    .collect(Collectors.joining(",")));
            } else {
                System.out.print("\tElr: N/A");
            }

            if (info.lastKnownElr() != null) {
                System.out.print("\tLastKnownElr: " + info.lastKnownElr().stream()
                    .map(node -> Integer.toString(node.id()))
                    .collect(Collectors.joining(",")));
            } else {
                System.out.print("\tLastKnownElr: N/A");
            }
            System.out.println();
        }
    }

    static class DescribeOptions {
        private final TopicCommandOptions opts;
        private final Set<Integer> liveBrokers;
        private final boolean describeConfigs;
        private final boolean describePartitions;

        DescribeOptions(TopicCommandOptions opts, Set<Integer> liveBrokers) {
            this.opts = opts;
            this.liveBrokers = liveBrokers;
            this.describeConfigs = !opts.reportUnavailablePartitions() &&
                !opts.reportUnderReplicatedPartitions() &&
                !opts.reportUnderMinIsrPartitions() &&
                !opts.reportAtMinIsrPartitions();
            this.describePartitions = !opts.reportOverriddenConfigs();
        }

        private boolean shouldPrintUnderReplicatedPartitions(PartitionDescription partitionDescription) {
            return opts.reportUnderReplicatedPartitions() && partitionDescription.isUnderReplicated();
        }

        private boolean shouldPrintUnavailablePartitions(PartitionDescription partitionDescription) {
            return opts.reportUnavailablePartitions() && partitionDescription.hasUnavailablePartitions(liveBrokers);
        }

        private boolean shouldPrintUnderMinIsrPartitions(PartitionDescription partitionDescription) {
            return opts.reportUnderMinIsrPartitions() && partitionDescription.isUnderMinIsr();
        }

        private boolean shouldPrintAtMinIsrPartitions(PartitionDescription partitionDescription) {
            return opts.reportAtMinIsrPartitions() && partitionDescription.isAtMinIsrPartitions();
        }

        private boolean shouldPrintTopicPartition(PartitionDescription partitionDesc) {
            return describeConfigs ||
                shouldPrintUnderReplicatedPartitions(partitionDesc) ||
                shouldPrintUnavailablePartitions(partitionDesc) ||
                shouldPrintUnderMinIsrPartitions(partitionDesc) ||
                shouldPrintAtMinIsrPartitions(partitionDesc);
        }

        void maybePrintPartitionDescription(PartitionDescription desc) {
            if (shouldPrintTopicPartition(desc)) {
                desc.printDescription();
            }
        }
    }

    static class TopicService implements AutoCloseable {
        private final Admin adminClient;

        TopicService(Properties commandConfig, Optional<String> bootstrapServer) {
            bootstrapServer.ifPresent(s -> commandConfig.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, s));

            adminClient = Admin.create(commandConfig);
        }

        TopicService(Admin admin) {
            adminClient = admin;
        }

        void createTopic(TopicCommandOptions opts) throws Exception {
            CommandTopicPartition topic = new CommandTopicPartition(opts);
            if (Topic.hasCollisionChars(topic.name)) {
                System.out.println("WARNING: Due to limitations in metric names, topics with a period ('.') or underscore ('_') could " +
                    "collide. To avoid issues it is best to use either, but not both.");
            }
            createTopic(topic);
        }

        private void createTopic(CommandTopicPartition topic) throws Exception {
            if (topic.replicationFactor.filter(rf -> rf > Short.MAX_VALUE || rf < 1).isPresent()) {
                throw new IllegalArgumentException("The replication factor must be between 1 and " + Short.MAX_VALUE + " inclusive");
            }
            if (topic.partitions.filter(p -> p < 1).isPresent()) {
                throw new IllegalArgumentException("The partitions must be greater than 0");
            }

            try {
                NewTopic newTopic = topic.hasReplicaAssignment()
                    ? new NewTopic(topic.name, topic.replicaAssignment)
                    : new NewTopic(topic.name, topic.partitions, topic.replicationFactor.map(Integer::shortValue));

                newTopic.configs(topic.configsToAdd);
                CreateTopicsResult createResult = adminClient.createTopics(Set.of(newTopic),
                    new CreateTopicsOptions().retryOnQuotaViolation(false));
                createResult.all().get();
                System.out.println("Created topic " + topic.name + ".");
            } catch (ExecutionException e) {
                if (e.getCause() == null) {
                    throw e;
                }
                if (!(e.getCause() instanceof TopicExistsException && topic.ifTopicDoesntExist())) {
                    throw (Exception) e.getCause();
                }
            }
        }

        void listTopics(TopicCommandOptions opts) throws ExecutionException, InterruptedException {
            String results = String.join("\n", getTopics(opts.topic(), opts.excludeInternalTopics()));
            System.out.println(results);
        }

        void alterTopic(TopicCommandOptions opts) throws ExecutionException, InterruptedException {
            CommandTopicPartition topic = new CommandTopicPartition(opts);
            List<String> topics = getTopics(opts.topic(), opts.excludeInternalTopics());
            ensureTopicExists(topics, opts.topic(), !opts.ifExists());

            if (!topics.isEmpty()) {
                Map<String, KafkaFuture<org.apache.kafka.clients.admin.TopicDescription>> topicsInfo = adminClient.describeTopics(topics).topicNameValues();
                Map<String, NewPartitions> newPartitions = topics.stream()
                    .map(topicName -> topicNewPartitions(topic, topicsInfo, topicName))
                    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
                adminClient.createPartitions(newPartitions, new CreatePartitionsOptions().retryOnQuotaViolation(false)).all().get();
            }
        }

        private AbstractMap.SimpleEntry<String, NewPartitions> topicNewPartitions(
            CommandTopicPartition topic,
            Map<String, KafkaFuture<org.apache.kafka.clients.admin.TopicDescription>> topicsInfo,
            String topicName) {
            if (topic.hasReplicaAssignment()) {
                try {
                    int startPartitionId = topicsInfo.get(topicName).get().partitions().size();
                    Map<Integer, List<Integer>> replicaMap = topic.replicaAssignment.entrySet().stream()
                        .skip(startPartitionId)
                        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
                    List<List<Integer>> newAssignment = new ArrayList<>(replicaMap.values());
                    return new AbstractMap.SimpleEntry<>(topicName, NewPartitions.increaseTo(topic.partitions.get(), newAssignment));
                } catch (InterruptedException | ExecutionException e) {
                    throw new RuntimeException(e);
                }
            }
            return new AbstractMap.SimpleEntry<>(topicName, NewPartitions.increaseTo(topic.partitions.get()));
        }

        Map<TopicPartition, PartitionReassignment> listAllReassignments(Set<TopicPartition> topicPartitions) {
            try {
                return adminClient.listPartitionReassignments(topicPartitions).reassignments().get();
            } catch (ExecutionException e) {
                Throwable cause = e.getCause();
                if (cause instanceof UnsupportedVersionException || cause instanceof ClusterAuthorizationException) {
                    LOG.debug("Couldn't query reassignments through the AdminClient API: " + cause.getMessage(), cause);
                    return Map.of();
                } else {
                    throw new RuntimeException(e);
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        void describeTopic(TopicCommandOptions opts) throws ExecutionException, InterruptedException {
            // If topicId is provided and not zero, will use topicId regardless of topic name
            Optional<Uuid> inputTopicId = opts.topicId()
                .map(Uuid::fromString).filter(uuid -> !uuid.equals(Uuid.ZERO_UUID));
            boolean useTopicId = inputTopicId.isPresent();

            List<Uuid> topicIds;
            List<String> topics;
            if (useTopicId) {
                topicIds = getTopicIds(inputTopicId.get(), opts.excludeInternalTopics());
                topics = List.of();
            } else {
                topicIds = List.of();
                topics = getTopics(opts.topic(), opts.excludeInternalTopics());
            }

            // Only check topic name when topicId is not provided
            if (useTopicId) {
                ensureTopicIdExists(topicIds, inputTopicId.get(), !opts.ifExists());
            } else {
                ensureTopicExists(topics, opts.topic(), !opts.ifExists());
            }
            List<org.apache.kafka.clients.admin.TopicDescription> topicDescriptions = new ArrayList<>();

            if (!topicIds.isEmpty()) {
                Map<Uuid, org.apache.kafka.clients.admin.TopicDescription> descTopics =
                    adminClient.describeTopics(TopicCollection.ofTopicIds(topicIds)).allTopicIds().get();
                topicDescriptions = new ArrayList<>(descTopics.values());
            }

            if (!topics.isEmpty()) {
                Map<String, org.apache.kafka.clients.admin.TopicDescription> descTopics =
                    adminClient.describeTopics(TopicCollection.ofTopicNames(topics),
                        new DescribeTopicsOptions()
                            .partitionSizeLimitPerResponse(opts.partitionSizeLimitPerResponse().orElse(2000))).allTopicNames().get();
                topicDescriptions = new ArrayList<>(descTopics.values());
            }

            List<String> topicNames = topicDescriptions.stream()
                .map(org.apache.kafka.clients.admin.TopicDescription::name)
                .toList();
            Map<ConfigResource, KafkaFuture<Config>> allConfigs = adminClient.describeConfigs(
                topicNames.stream()
                    .map(name -> new ConfigResource(ConfigResource.Type.TOPIC, name))
                    .collect(Collectors.toList())
            ).values();
            List<Integer> liveBrokers = adminClient.describeCluster().nodes().get().stream()
                .map(Node::id)
                .toList();
            DescribeOptions describeOptions = new DescribeOptions(opts, new HashSet<>(liveBrokers));
            Set<TopicPartition> topicPartitions = topicDescriptions
                .stream()
                .flatMap(td -> td.partitions().stream()
                    .map(p -> new TopicPartition(td.name(), p.partition())))
                .collect(Collectors.toSet());
            Map<TopicPartition, PartitionReassignment> reassignments = listAllReassignments(topicPartitions);
            for (org.apache.kafka.clients.admin.TopicDescription td : topicDescriptions) {
                String topicName = td.name();
                Config config = allConfigs.get(new ConfigResource(ConfigResource.Type.TOPIC, topicName)).get();
                ArrayList<TopicPartitionInfo> sortedPartitions = new ArrayList<>(td.partitions());
                sortedPartitions.sort(Comparator.comparingInt(TopicPartitionInfo::partition));
                printDescribeConfig(opts, describeOptions, reassignments, td, topicName, td.topicId(), config, sortedPartitions);
                printPartitionDescription(describeOptions, reassignments, td, topicName, config, sortedPartitions);
            }
        }

        private void printPartitionDescription(DescribeOptions describeOptions, Map<TopicPartition, PartitionReassignment> reassignments,
                                               org.apache.kafka.clients.admin.TopicDescription td, String topicName, Config config,
                                               ArrayList<TopicPartitionInfo> sortedPartitions) {
            if (describeOptions.describePartitions) {
                for (TopicPartitionInfo partition : sortedPartitions) {
                    PartitionReassignment reassignment =
                        reassignments.get(new TopicPartition(td.name(), partition.partition()));
                    PartitionDescription partitionDesc = new PartitionDescription(topicName,
                        partition, config, reassignment);
                    describeOptions.maybePrintPartitionDescription(partitionDesc);
                }
            }
        }

        private void printDescribeConfig(TopicCommandOptions opts, DescribeOptions describeOptions, Map<TopicPartition, PartitionReassignment> reassignments,
                                         org.apache.kafka.clients.admin.TopicDescription td, String topicName, Uuid topicId,
                                         Config config, ArrayList<TopicPartitionInfo> sortedPartitions) {
            if (describeOptions.describeConfigs) {
                List<ConfigEntry> entries = new ArrayList<>(config.entries());
                boolean hasNonDefault = entries.stream().anyMatch(e -> !e.isDefault());
                if (!opts.reportOverriddenConfigs() || hasNonDefault) {
                    TopicPartitionInfo firstPartition = sortedPartitions.get(0);
                    PartitionReassignment reassignment =
                        reassignments.get(new TopicPartition(td.name(), firstPartition.partition()));
                    TopicDescription topicDesc = new TopicDescription(topicName, topicId,
                        td.partitions().size(), getReplicationFactor(firstPartition, reassignment), config);
                    topicDesc.printDescription();
                }
            }
        }

        void deleteTopic(TopicCommandOptions opts) throws ExecutionException, InterruptedException {
            List<String> topics = getTopics(opts.topic(), opts.excludeInternalTopics());
            ensureTopicExists(topics, opts.topic(), !opts.ifExists());
            adminClient.deleteTopics(List.copyOf(topics),
                new DeleteTopicsOptions().retryOnQuotaViolation(false)
            ).all().get();
        }

        List<String> getTopics(Optional<String> topicIncludeList, boolean excludeInternalTopics) throws ExecutionException, InterruptedException {
            ListTopicsOptions listTopicsOptions = new ListTopicsOptions();
            if (!excludeInternalTopics) {
                listTopicsOptions.listInternal(true);
            }

            Set<String> allTopics = adminClient.listTopics(listTopicsOptions).names().get();
            return doGetTopics(allTopics.stream().sorted().collect(Collectors.toList()), topicIncludeList, excludeInternalTopics);
        }

        private List<Uuid> getTopicIds(Uuid topicIdIncludeList, boolean excludeInternalTopics) throws ExecutionException, InterruptedException {
            ListTopicsResult allTopics = excludeInternalTopics ? adminClient.listTopics() :
                adminClient.listTopics(new ListTopicsOptions().listInternal(true));
            List<Uuid> allTopicIds = allTopics.listings().get().stream()
                .map(TopicListing::topicId)
                .sorted()
                .toList();
            return allTopicIds.contains(topicIdIncludeList) ?
                List.of(topicIdIncludeList) :
                List.of();
        }

        @Override
        public void close() throws Exception {
            adminClient.close();
        }
    }

    static final class TopicCommandOptions extends CommandDefaultOptions {
        private final ArgumentAcceptingOptionSpec<String> bootstrapServerOpt;

        private final ArgumentAcceptingOptionSpec<String> commandConfigOpt;

        private final OptionSpecBuilder listOpt;

        private final OptionSpecBuilder createOpt;

        private final OptionSpecBuilder deleteOpt;

        private final OptionSpecBuilder alterOpt;

        private final OptionSpecBuilder describeOpt;

        private final ArgumentAcceptingOptionSpec<String> topicOpt;

        private final ArgumentAcceptingOptionSpec<String> topicIdOpt;

        private static final String KAFKA_CONFIGS_CLI_SUPPORTS_ALTERING_TOPIC_CONFIGS =
                " (To alter topic configurations, the kafka-configs tool can be used.)";

        private final ArgumentAcceptingOptionSpec<String> configOpt;

        /**
         * @deprecated Since 4.0 and should not be used any longer.
         */
        @Deprecated
        private final ArgumentAcceptingOptionSpec<String> deleteConfigOpt;

        private final ArgumentAcceptingOptionSpec<Integer> partitionsOpt;

        private final ArgumentAcceptingOptionSpec<Integer> replicationFactorOpt;

        private final ArgumentAcceptingOptionSpec<String> replicaAssignmentOpt;

        private final OptionSpecBuilder reportUnderReplicatedPartitionsOpt;

        private final OptionSpecBuilder reportUnavailablePartitionsOpt;

        private final OptionSpecBuilder reportUnderMinIsrPartitionsOpt;

        private final OptionSpecBuilder reportAtMinIsrPartitionsOpt;

        private final OptionSpecBuilder topicsWithOverridesOpt;

        private final OptionSpecBuilder ifExistsOpt;

        private final OptionSpecBuilder ifNotExistsOpt;

        private final OptionSpecBuilder excludeInternalTopicOpt;

        private final ArgumentAcceptingOptionSpec<Integer> partitionSizeLimitPerResponseOpt;

        private final Set<OptionSpec<?>> allTopicLevelOpts;

        private final Set<OptionSpecBuilder> allReplicationReportOpts;

        TopicCommandOptions(String[] args) {
            super(args);
            bootstrapServerOpt = parser.accepts("bootstrap-server", "REQUIRED: The Kafka server to connect to.")
                .withRequiredArg()
                .describedAs("server to connect to")
                .ofType(String.class);
            commandConfigOpt = parser.accepts("command-config", "Property file containing configs to be passed to Admin Client.")
                .withRequiredArg()
                .describedAs("command config property file")
                .ofType(String.class);

            listOpt = parser.accepts("list", "List all available topics.");
            createOpt = parser.accepts("create", "Create a new topic.");
            deleteOpt = parser.accepts("delete", "Delete a topic.");
            alterOpt = parser.accepts("alter", "Alter the number of partitions and replica assignment." +
                    KAFKA_CONFIGS_CLI_SUPPORTS_ALTERING_TOPIC_CONFIGS);
            describeOpt = parser.accepts("describe", "List details for the given topics.");
            topicOpt = parser.accepts("topic", "The topic to create, alter, describe or delete. It also accepts a regular " +
                            "expression, except for --create option.")
                .withRequiredArg()
                .describedAs("topic")
                .ofType(String.class);
            topicIdOpt = parser.accepts("topic-id", "The topic-id to describe.")
                .withRequiredArg()
                .describedAs("topic-id")
                .ofType(String.class);
            String nl = System.lineSeparator();

            String logConfigNames = LogConfig.nonInternalConfigNames().stream().map(config -> "\t" + config).collect(Collectors.joining(nl));
            configOpt = parser.accepts("config",  "A topic configuration override for the topic being created." +
                            " The following is a list of valid configurations: " + nl + logConfigNames + nl +
                            "See the Kafka documentation for full details on the topic configs." +
                            " It is supported only in combination with --create." +
                            KAFKA_CONFIGS_CLI_SUPPORTS_ALTERING_TOPIC_CONFIGS)
                .withRequiredArg()
                .describedAs("name=value")
                .ofType(String.class);

            deleteConfigOpt = parser.accepts("delete-config", "This option is no longer supported and has been deprecated since 4.0")
                .withRequiredArg()
                .describedAs("name")
                .ofType(String.class);
            partitionsOpt = parser.accepts("partitions", "The number of partitions for the topic being created or " +
                    "altered. If not supplied with --create, the topic uses the cluster default. (WARNING: If partitions are increased for a topic that has a key, the partition logic or ordering of the messages will be affected).")
                .withRequiredArg()
                .describedAs("# of partitions")
                .ofType(java.lang.Integer.class);
            replicationFactorOpt = parser.accepts("replication-factor", "The replication factor for each partition in the topic being created. If not supplied, the topic uses the cluster default.")
                .withRequiredArg()
                .describedAs("replication factor")
                .ofType(java.lang.Integer.class);
            replicaAssignmentOpt = parser.accepts("replica-assignment", "A list of manual partition-to-broker assignments for the topic being created or altered.")
                    .withRequiredArg()
                    .describedAs("broker_id_for_part1_replica1 : broker_id_for_part1_replica2 , " +
                            "broker_id_for_part2_replica1 : broker_id_for_part2_replica2 , ...")
                .ofType(String.class);
            reportUnderReplicatedPartitionsOpt = parser.accepts("under-replicated-partitions",
                "If set when describing topics, only show under-replicated partitions.");
            reportUnavailablePartitionsOpt = parser.accepts("unavailable-partitions",
                "If set when describing topics, only show partitions whose leader is not available.");
            reportUnderMinIsrPartitionsOpt = parser.accepts("under-min-isr-partitions",
                "If set when describing topics, only show partitions whose isr count is less than the configured minimum.");
            reportAtMinIsrPartitionsOpt = parser.accepts("at-min-isr-partitions",
                "If set when describing topics, only show partitions whose isr count is equal to the configured minimum.");
            topicsWithOverridesOpt = parser.accepts("topics-with-overrides",
                "If set when describing topics, only show topics that have overridden configs.");
            ifExistsOpt = parser.accepts("if-exists",
                "If set when altering or deleting or describing topics, the action will only execute if the topic exists.");
            ifNotExistsOpt = parser.accepts("if-not-exists",
                "If set when creating topics, the create request will not fail if the topic already exists, but the request will still be sent.");
            excludeInternalTopicOpt = parser.accepts("exclude-internal",
                "Exclude internal topics when listing or describing topics. By default, the internal topics are included.");
            partitionSizeLimitPerResponseOpt = parser.accepts("partition-size-limit-per-response",
                "The maximum partition size to be included in one DescribeTopicPartitions response.")
                    .withRequiredArg()
                    .describedAs("maximum number of partitions per response")
                    .ofType(java.lang.Integer.class);
            options = parser.parse(args);

            allTopicLevelOpts = Set.of(alterOpt, createOpt, describeOpt, listOpt, deleteOpt);
            allReplicationReportOpts = Set.of(reportUnderReplicatedPartitionsOpt, reportUnderMinIsrPartitionsOpt, reportAtMinIsrPartitionsOpt, reportUnavailablePartitionsOpt);

            checkArgs();
        }

        boolean has(OptionSpec<?> builder) {
            return options.has(builder);
        }

        <A> Optional<A> valueAsOption(OptionSpec<A> option) {
            return has(option) ? Optional.of(options.valueOf(option)) : Optional.empty();
        }

        boolean hasCreateOption() {
            return has(createOpt);
        }

        boolean hasAlterOption() {
            return has(alterOpt);
        }

        boolean hasListOption() {
            return has(listOpt);
        }

        boolean hasDescribeOption() {
            return has(describeOpt);
        }

        boolean hasDeleteOption() {
            return has(deleteOpt);
        }

        Optional<String> bootstrapServer() {
            return valueAsOption(bootstrapServerOpt);
        }

        Properties commandConfig() throws IOException {
            return has(commandConfigOpt) ? Utils.loadProps(options.valueOf(commandConfigOpt)) : new Properties();
        }

        Optional<String> topic() {
            return valueAsOption(topicOpt);
        }

        Optional<String> topicId() {
            return valueAsOption(topicIdOpt);
        }

        Optional<Integer> partitions() {
            return valueAsOption(partitionsOpt);
        }

        Optional<Integer> replicationFactor() {
            return valueAsOption(replicationFactorOpt);
        }

        Optional<Map<Integer, List<Integer>>> replicaAssignment() {
            return has(replicaAssignmentOpt) && !Optional.of(options.valueOf(replicaAssignmentOpt)).orElse("").isEmpty()
                ? Optional.of(parseReplicaAssignment(options.valueOf(replicaAssignmentOpt)))
                : Optional.empty();
        }

        boolean reportUnderReplicatedPartitions() {
            return has(reportUnderReplicatedPartitionsOpt);
        }

        boolean reportUnavailablePartitions() {
            return has(reportUnavailablePartitionsOpt);
        }

        boolean reportUnderMinIsrPartitions() {
            return has(reportUnderMinIsrPartitionsOpt);
        }

        boolean reportAtMinIsrPartitions() {
            return has(reportAtMinIsrPartitionsOpt);
        }

        boolean reportOverriddenConfigs() {
            return has(topicsWithOverridesOpt);
        }

        boolean ifExists() {
            return has(ifExistsOpt);
        }

        boolean ifNotExists() {
            return has(ifNotExistsOpt);
        }

        boolean excludeInternalTopics() {
            return has(excludeInternalTopicOpt);
        }

        Optional<Integer> partitionSizeLimitPerResponse() {
            return valueAsOption(partitionSizeLimitPerResponseOpt);
        }

        Optional<List<String>> topicConfig() {
            return options.has(configOpt) ? Optional.of(options.valuesOf(configOpt)) : Optional.of(List.of());
        }

        private void checkArgs() {
            if (args.length == 0)
                CommandLineUtils.printUsageAndExit(parser, "Create, delete, describe, or change a topic.");

            CommandLineUtils.maybePrintHelpOrVersion(this, "This tool helps to create, delete, describe, or change a topic.");

            // should have exactly one action
            long actions = Stream.of(createOpt, listOpt, alterOpt, describeOpt, deleteOpt).filter(options::has).count();

            if (actions != 1)
                CommandLineUtils.printUsageAndExit(parser, "Command must include exactly one action: --list, --describe, --create, --alter or --delete");

            if (has(deleteConfigOpt)) {
                System.err.println("delete-config option is no longer supported and deprecated since version 4.0. The config will be fully removed in future releases.");
            }

            checkRequiredArgs();
            checkInvalidArgs();
        }

        private void checkRequiredArgs() {
            // check required args
            if (!has(bootstrapServerOpt))
                throw new IllegalArgumentException("--bootstrap-server must be specified");
            if (has(describeOpt) && has(ifExistsOpt)) {
                if (!has(topicOpt) && !has(topicIdOpt))
                    CommandLineUtils.printUsageAndExit(parser, "--topic or --topic-id is required to describe a topic");
                if (has(topicOpt) && has(topicIdOpt))
                    System.out.println("Only topic id will be used when both --topic and --topic-id are specified and topicId is not Uuid.ZERO_UUID");
            }
            if (!has(listOpt) && !has(describeOpt))
                CommandLineUtils.checkRequiredArgs(parser, options, topicOpt);
            if (has(alterOpt)) {
                Set<OptionSpec<?>> usedOptions = Set.of(bootstrapServerOpt, configOpt);
                Set<OptionSpec<?>> invalidOptions = Set.of(alterOpt);
                CommandLineUtils.checkInvalidArgsSet(parser, options, usedOptions, invalidOptions, Optional.of(KAFKA_CONFIGS_CLI_SUPPORTS_ALTERING_TOPIC_CONFIGS));
                CommandLineUtils.checkRequiredArgs(parser, options, partitionsOpt);
            }
        }

        private void checkInvalidArgs() {
            // check invalid args
            CommandLineUtils.checkInvalidArgs(parser, options, configOpt, invalidOptions(List.of(alterOpt, createOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, partitionsOpt, invalidOptions(List.of(alterOpt, createOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, replicationFactorOpt, invalidOptions(List.of(createOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, replicaAssignmentOpt, invalidOptions(List.of(alterOpt, createOpt)));
            if (options.has(createOpt)) {
                CommandLineUtils.checkInvalidArgs(parser, options, replicaAssignmentOpt, partitionsOpt, replicationFactorOpt);
            }

            CommandLineUtils.checkInvalidArgs(parser, options, reportUnderReplicatedPartitionsOpt,
                invalidOptions(Set.of(topicsWithOverridesOpt), List.of(describeOpt, reportUnderReplicatedPartitionsOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, reportUnderMinIsrPartitionsOpt,
                invalidOptions(Set.of(topicsWithOverridesOpt), List.of(describeOpt, reportUnderMinIsrPartitionsOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, reportAtMinIsrPartitionsOpt,
                invalidOptions(Set.of(topicsWithOverridesOpt), List.of(describeOpt, reportAtMinIsrPartitionsOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, reportUnavailablePartitionsOpt,
                invalidOptions(Set.of(topicsWithOverridesOpt), List.of(describeOpt, reportUnavailablePartitionsOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, topicsWithOverridesOpt,
                invalidOptions(new HashSet<>(allReplicationReportOpts), List.of(describeOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, ifExistsOpt,
                invalidOptions(List.of(alterOpt, deleteOpt, describeOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, ifNotExistsOpt, invalidOptions(List.of(createOpt)));
            CommandLineUtils.checkInvalidArgs(parser, options, excludeInternalTopicOpt, invalidOptions(List.of(listOpt, describeOpt)));
        }

        private Set<OptionSpec<?>> invalidOptions(List<OptionSpec<?>> removeOptions) {
            return invalidOptions(new HashSet<>(), removeOptions);
        }

        private LinkedHashSet<OptionSpec<?>> invalidOptions(Set<OptionSpec<?>> addOptions, List<OptionSpec<?>> removeOptions) {
            LinkedHashSet<OptionSpec<?>> finalOptions = new LinkedHashSet<>(allTopicLevelOpts);
            finalOptions.removeAll(removeOptions);
            finalOptions.addAll(addOptions);
            return finalOptions;
        }
    }
}
