// Copyright 2025 The Bazel Authors. All rights reserved.
//
// 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.
package com.google.devtools.build.lib.runtime;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static java.util.concurrent.TimeUnit.MILLISECONDS;

import com.google.common.eventbus.Subscribe;
import com.google.common.flogger.GoogleLogger;
import com.google.common.util.concurrent.Uninterruptibles;
import com.google.devtools.build.lib.actions.ActionExecutionInactivityEvent;
import com.google.devtools.build.lib.buildeventstream.BuildEventArtifactUploader;
import com.google.devtools.build.lib.buildeventstream.BuildEventProtocolOptions;
import com.google.devtools.build.lib.buildtool.BuildResult.BuildToolLogCollection;
import com.google.devtools.build.lib.buildtool.buildevent.BuildCompleteEvent;
import com.google.devtools.build.lib.clock.Clock;
import com.google.devtools.build.lib.events.Event;
import com.google.devtools.build.lib.profiler.Profiler;
import com.google.devtools.build.lib.runtime.BuildEventArtifactUploaderFactory.InvalidPackagePathSymlinkException;
import com.google.devtools.build.lib.runtime.InstrumentationOutputFactory.DestinationRelativeTo;
import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
import com.google.devtools.build.lib.util.AbruptExitException;
import com.google.devtools.build.lib.util.DetailedExitCode;
import com.google.devtools.build.lib.util.ExitCode;
import com.google.devtools.build.lib.util.ThreadDumpAnalyzer;
import com.google.devtools.build.lib.util.ThreadDumper;
import com.google.devtools.build.lib.vfs.PathFragment;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import javax.annotation.Nullable;

/** A {@link BlazeModule} that dumps the state of all threads periodically. */
public final class ThreadDumpModule extends BlazeModule {
  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
  private static final DateTimeFormatter TIME_FORMAT =
      DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

  private final AtomicReference<ThreadDumpTask> threadDumpTaskRef = new AtomicReference<>();

  @Override
  public void beforeCommand(CommandEnvironment env) throws AbruptExitException {
    var commandOptions = env.getOptions().getOptions(CommonCommandOptions.class);
    if (commandOptions == null || !commandOptions.getEnableThreadDump()) {
      return;
    }

    if (commandOptions.getThreadDumpInterval().isZero()
        && commandOptions.getThreadDumpActionExecutionInactivityDuration().isZero()) {
      env.getReporter()
          .handle(
              Event.warn(
                  "--experimental_enable_thread_dump is set, but"
                      + " --experimental_thread_dump_interval and"
                      + " --experimental_thread_dump_action_execution_inactivity_duration are 0. No"
                      + " thread dumps will be written."));
      return;
    }

    var bepOptions = env.getOptions().getOptions(BuildEventProtocolOptions.class);
    BuildEventArtifactUploader uploader = null;
    if (bepOptions != null && bepOptions.getStreamingLogFileUploads()) {
      try {
        uploader = newUploader(env, bepOptions);
      } catch (InvalidPackagePathSymlinkException e) {
        throw createAbruptExitException("Failed to create uploader", e);
      }
    }

    var outputBaseRelativeDumpDirectory = prepareDumpDirectory(env);
    var threadDumpTask =
        new ThreadDumpTask(
            env,
            ProcessHandle.current().pid(),
            env.getRuntime().getClock(),
            outputBaseRelativeDumpDirectory,
            commandOptions.getThreadDumpActionExecutionInactivityDuration(),
            commandOptions.getThreadDumpInterval(),
            uploader);
    var oldThreadDumpTask = threadDumpTaskRef.getAndSet(threadDumpTask);
    checkState(oldThreadDumpTask == null);

    env.getEventBus().register(this);
  }

  private static BuildEventArtifactUploader newUploader(
      CommandEnvironment env, BuildEventProtocolOptions bepOptions)
      throws InvalidPackagePathSymlinkException {
    return env.getRuntime()
        .getBuildEventArtifactUploaderFactoryMap()
        .select(bepOptions.getBuildEventUploadStrategy())
        .create(env);
  }

  private PathFragment prepareDumpDirectory(CommandEnvironment env) throws AbruptExitException {
    var runtime = env.getRuntime();
    var serverDirectory = runtime.getServerDirectory();
    var dumpDirectory = serverDirectory.getChild("thread_dumps");
    try {
      dumpDirectory.deleteTree();
      dumpDirectory.createDirectoryAndParents();
    } catch (IOException e) {
      throw createAbruptExitException("Failed to setup thread dump directory", e);
    }
    return dumpDirectory.relativeTo(env.getDirectories().getOutputBase());
  }

  private static AbruptExitException createAbruptExitException(String message, Throwable cause) {
    return new AbruptExitException(
        DetailedExitCode.of(
            ExitCode.LOCAL_ENVIRONMENTAL_ERROR,
            FailureDetail.newBuilder().setMessage(message).build()),
        cause);
  }

  @Subscribe
  public void onActionExecutionInactivityEvent(ActionExecutionInactivityEvent event) {
    var threadDumpTask = threadDumpTaskRef.get();
    checkNotNull(threadDumpTask);
    threadDumpTask.onActionExecutionInactivityEvent(event);
  }

  @Subscribe
  public void buildComplete(BuildCompleteEvent event) {
    shutdown(event.getResult().getBuildToolLogCollection());
  }

  private void shutdown(@Nullable BuildToolLogCollection buildToolLogCollection) {
    // We might get concurrent call to shutdown (via afterCommand).
    var threadDumpTask = threadDumpTaskRef.getAndSet(null);
    if (threadDumpTask != null) {
      threadDumpTask.shutdown(buildToolLogCollection);
    }
  }

  @Override
  public void afterCommand() {
    // Defensively shut down in case we failed to do so under normal operation.
    shutdown(/* buildToolLogCollection= */ null);
  }

  private static final class ThreadDumpTask implements Runnable {
    private final CommandEnvironment env;
    private final long pid;
    private final Clock clock;
    private final PathFragment outputBaseRelativeDumpDirectory;
    private final Duration threadDumpActionExecutionInactivityDuration;
    @Nullable private final BuildEventArtifactUploader uploader;
    private final ScheduledExecutorService scheduledExecutor;

    private final List<InstrumentationOutput> instrumentationOutputs =
        Collections.synchronizedList(new ArrayList<>());

    private Instant lastDumpAt = Instant.EPOCH;

    private ThreadDumpTask(
        CommandEnvironment env,
        long pid,
        Clock clock,
        PathFragment outputBaseRelativeDumpDirectory,
        Duration threadDumpActionExecutionInactivityDuration,
        Duration threadDumpInterval,
        @Nullable BuildEventArtifactUploader uploader) {
      this.env = env;
      this.pid = pid;
      this.clock = clock;
      this.outputBaseRelativeDumpDirectory = outputBaseRelativeDumpDirectory;
      this.threadDumpActionExecutionInactivityDuration =
          threadDumpActionExecutionInactivityDuration;
      this.uploader = uploader;
      this.scheduledExecutor = Executors.newSingleThreadScheduledExecutor();

      if (!threadDumpInterval.isZero()) {
        var unused =
            scheduledExecutor.scheduleAtFixedRate(
                this, threadDumpInterval.toMillis(), threadDumpInterval.toMillis(), MILLISECONDS);
      }
    }

    @Override
    public void run() {
      var bos = new ByteArrayOutputStream();
      try (var sc = Profiler.instance().profile("Dumping threads")) {
        ThreadDumper.dumpThreads(bos);
      } catch (IOException e) {
        logger.atWarning().withCause(e).log("Failed to dump threads.");
      }

      String formattedTime =
          Instant.ofEpochMilli(clock.currentTimeMillis())
              .atZone(ZoneOffset.UTC)
              .format(TIME_FORMAT);
      var dumpOutput =
          createThreadDumpOutput(String.format("thread_dump.%d.%s.txt", pid, formattedTime));
      instrumentationOutputs.add(dumpOutput);
      var analyzer = new ThreadDumpAnalyzer();
      try (var sc = Profiler.instance().profile("Analyzing thread dump");
          var out = dumpOutput.createOutputStream()) {
        analyzer.analyze(new ByteArrayInputStream(bos.toByteArray()), out);
      } catch (IOException e) {
        logger.atWarning().withCause(e).log("Failed to analyze threads.");
      }

      lastDumpAt = clock.now();
    }

    private boolean shouldDumpForActionExecutionInactivity(ActionExecutionInactivityEvent event) {
      if (threadDumpActionExecutionInactivityDuration.isZero()) {
        return false;
      }

      var now = clock.now();
      if (now.isBefore(
          event.lastActionCompletedAt().plus(threadDumpActionExecutionInactivityDuration))) {
        return false;
      }

      return now.isAfter(lastDumpAt.plus(threadDumpActionExecutionInactivityDuration));
    }

    void onActionExecutionInactivityEvent(ActionExecutionInactivityEvent event) {
      if (shouldDumpForActionExecutionInactivity(event)) {
        var unused = scheduledExecutor.schedule(this, 0, MILLISECONDS);
      }
    }

    void shutdown(@Nullable BuildToolLogCollection buildToolLogCollection) {
      scheduledExecutor.shutdownNow();
      try (var sc = Profiler.instance().profile("Joining dump thread")) {
        Uninterruptibles.awaitTerminationUninterruptibly(scheduledExecutor);
      }

      if (buildToolLogCollection != null) {
        for (var output : instrumentationOutputs) {
          output.publish(buildToolLogCollection);
        }
      }

      if (uploader != null) {
        uploader.release();
      }
    }

    private InstrumentationOutput createThreadDumpOutput(String name) {
      var outputFactory = env.getRuntime().getInstrumentationOutputFactory();
      if (uploader != null) {
        return outputFactory.createBuildEventArtifactInstrumentationOutput(name, uploader);
      }
      return outputFactory.createInstrumentationOutput(
          /* name= */ name,
          /* destination= */ outputBaseRelativeDumpDirectory.getRelative(name),
          DestinationRelativeTo.OUTPUT_BASE,
          env,
          env.getReporter(),
          /* append= */ null,
          /* internal= */ null,
          /* createParent= */ true);
    }
  }
}
