// Copyright 2023 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.exec;

import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.GoogleLogger;
import com.google.devtools.build.lib.actions.ActionInput;
import com.google.devtools.build.lib.actions.Artifact.TreeFileArtifact;
import com.google.devtools.build.lib.actions.ExecException;
import com.google.devtools.build.lib.actions.FileArtifactValue.UnresolvedSymlinkArtifactValue;
import com.google.devtools.build.lib.actions.InputMetadataProvider;
import com.google.devtools.build.lib.actions.Spawn;
import com.google.devtools.build.lib.actions.SpawnResult;
import com.google.devtools.build.lib.actions.Spawns;
import com.google.devtools.build.lib.actions.cache.VirtualActionInput;
import com.google.devtools.build.lib.exec.Protos.Digest;
import com.google.devtools.build.lib.exec.Protos.File;
import com.google.devtools.build.lib.exec.Protos.Platform;
import com.google.devtools.build.lib.exec.Protos.SpawnExec;
import com.google.devtools.build.lib.profiler.Profiler;
import com.google.devtools.build.lib.profiler.SilentCloseable;
import com.google.devtools.build.lib.remote.options.RemoteOptions;
import com.google.devtools.build.lib.util.io.AsynchronousMessageOutputStream;
import com.google.devtools.build.lib.util.io.MessageInputStream;
import com.google.devtools.build.lib.util.io.MessageInputStreamWrapper.BinaryInputStreamWrapper;
import com.google.devtools.build.lib.util.io.MessageOutputStream;
import com.google.devtools.build.lib.util.io.MessageOutputStreamWrapper.BinaryOutputStreamWrapper;
import com.google.devtools.build.lib.util.io.MessageOutputStreamWrapper.JsonOutputStreamWrapper;
import com.google.devtools.build.lib.vfs.DigestHashFunction;
import com.google.devtools.build.lib.vfs.Dirent;
import com.google.devtools.build.lib.vfs.FileSystem;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.vfs.PathFragment;
import com.google.devtools.build.lib.vfs.Symlinks;
import com.google.devtools.build.lib.vfs.XattrProvider;
import java.io.IOException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.function.Consumer;
import javax.annotation.Nullable;

/** A {@link SpawnLogContext} implementation that produces a log in expanded format. */
public class ExpandedSpawnLogContext extends SpawnLogContext {

  /** The log encoding. */
  public enum Encoding {
    /** Length-delimited binary protos. */
    BINARY,
    /** Newline-delimited JSON messages. */
    JSON
  }

  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();

  private final Encoding encoding;
  private final boolean sorted;

  private final Path tempPath;
  private final Path outputPath;

  private final PathFragment execRoot;
  @Nullable private final RemoteOptions remoteOptions;
  private final DigestHashFunction digestHashFunction;
  private final XattrProvider xattrProvider;

  /** Output stream to write directly into during execution. */
  private final MessageOutputStream<SpawnExec> rawOutputStream;

  public ExpandedSpawnLogContext(
      Path outputPath,
      Path tempPath,
      Encoding encoding,
      boolean sorted,
      PathFragment execRoot,
      @Nullable RemoteOptions remoteOptions,
      DigestHashFunction digestHashFunction,
      XattrProvider xattrProvider)
      throws IOException {
    this.encoding = encoding;
    this.sorted = sorted;
    this.tempPath = tempPath;
    this.outputPath = outputPath;
    this.execRoot = execRoot;
    this.remoteOptions = remoteOptions;
    this.digestHashFunction = digestHashFunction;
    this.xattrProvider = xattrProvider;

    if (needsConversion()) {
      // Write the unsorted binary format into a temporary path first, then convert into the output
      // format after execution. Delete a preexisting output file so that an incomplete invocation
      // doesn't appear to produce a nonsensical log.
      outputPath.delete();
      rawOutputStream = getRawOutputStream(tempPath);
    } else {
      // The unsorted binary format can be written directly into the output path during execution.
      rawOutputStream = getRawOutputStream(outputPath);
    }
  }

  private boolean needsConversion() {
    return encoding != Encoding.BINARY || sorted;
  }

  private static MessageOutputStream<SpawnExec> getRawOutputStream(Path path) throws IOException {
    // Use an AsynchronousMessageOutputStream so that writes occur in a separate thread.
    // This ensures concurrent writes don't tear and avoids blocking execution.
    return new AsynchronousMessageOutputStream<>(path);
  }

  private MessageOutputStream<SpawnExec> getConvertedOutputStream(Path path) throws IOException {
    switch (encoding) {
      case BINARY:
        return new BinaryOutputStreamWrapper<>(path.getOutputStream());
      case JSON:
        return new JsonOutputStreamWrapper<>(path.getOutputStream());
    }
    throw new IllegalArgumentException(
        String.format("invalid execution log encoding: %s", encoding));
  }

  @Override
  public boolean shouldPublish() {
    // The expanded log tends to be too large to be uploaded to a remote store.
    return false;
  }

  @Override
  public void logSpawn(
      Spawn spawn,
      InputMetadataProvider inputMetadataProvider,
      SortedMap<PathFragment, ActionInput> inputMap,
      FileSystem fileSystem,
      Duration timeout,
      SpawnResult result)
      throws IOException, ExecException {
    try (SilentCloseable c = Profiler.instance().profile("logSpawn")) {
      SpawnExec.Builder builder = SpawnExec.newBuilder();
      builder.addAllCommandArgs(spawn.getArguments());
      builder.addAllEnvironmentVariables(getEnvironmentVariables(spawn));

      ImmutableSet<? extends ActionInput> toolFiles = spawn.getToolFiles().toSet();

      try (SilentCloseable c1 = Profiler.instance().profile("logSpawn/inputs")) {
        for (Map.Entry<PathFragment, ActionInput> e : inputMap.entrySet()) {
          PathFragment displayPath = e.getKey();
          ActionInput input = e.getValue();

          if (input instanceof VirtualActionInput.EmptyActionInput) {
            // Do not include a digest, as it's a waste of space.
            builder.addInputsBuilder().setPath(displayPath.getPathString());
            continue;
          }

          boolean isTool =
              toolFiles.contains(input)
                  || (input instanceof TreeFileArtifact
                      && toolFiles.contains(((TreeFileArtifact) input).getParent()));

          Path contentPath = fileSystem.getPath(execRoot.getRelative(input.getExecPathString()));

          if (isInputDirectory(input, contentPath, inputMetadataProvider)) {
            listDirectoryContents(
                displayPath, contentPath, builder::addInputs, inputMetadataProvider, isTool);
            continue;
          }

          if (input.isSymlink()) {
            UnresolvedSymlinkArtifactValue metadata =
                (UnresolvedSymlinkArtifactValue) inputMetadataProvider.getInputMetadata(input);
            builder
                .addInputsBuilder()
                .setPath(displayPath.getPathString())
                .setSymlinkTargetPath(metadata.getSymlinkTarget())
                .setIsTool(isTool);
            continue;
          }

          Digest digest =
              computeDigest(
                  input,
                  contentPath,
                  inputMetadataProvider,
                  xattrProvider,
                  digestHashFunction,
                  /* includeHashFunctionName= */ true);

          builder
              .addInputsBuilder()
              .setPath(displayPath.getPathString())
              .setDigest(digest)
              .setIsTool(isTool);
        }
      } catch (IOException e) {
        logger.atWarning().withCause(e).log("Error computing spawn input properties");
      }
      try (SilentCloseable c1 = Profiler.instance().profile("logSpawn/outputs")) {
        ArrayList<String> outputPaths = new ArrayList<>();
        for (ActionInput output : spawn.getOutputFiles()) {
          outputPaths.add(output.getExecPathString());
        }
        Collections.sort(outputPaths);
        builder.addAllListedOutputs(outputPaths);
        try {
          for (ActionInput output : spawn.getOutputFiles()) {
            Path path = fileSystem.getPath(execRoot.getRelative(output.getExecPathString()));
            if (!output.isDirectory() && !output.isSymlink() && path.isFile()) {
              builder
                  .addActualOutputsBuilder()
                  .setPath(output.getExecPathString())
                  .setDigest(
                      computeDigest(
                          output,
                          path,
                          inputMetadataProvider,
                          xattrProvider,
                          digestHashFunction,
                          /* includeHashFunctionName= */ true));
            } else if (!output.isSymlink() && path.isDirectory()) {
              // TODO(tjgq): Tighten once --incompatible_disallow_unsound_directory_outputs is gone.
              listDirectoryContents(
                  output.getExecPath(),
                  path,
                  builder::addActualOutputs,
                  inputMetadataProvider,
                  /* isTool= */ false);
            } else if (output.isSymlink() && path.isSymbolicLink()) {
              builder
                  .addActualOutputsBuilder()
                  .setPath(output.getExecPathString())
                  .setSymlinkTargetPath(path.readSymbolicLink().getPathString());
            }
          }
        } catch (IOException ex) {
          logger.atWarning().withCause(ex).log("Error computing spawn output properties");
        }
      }
      builder.setRemotable(Spawns.mayBeExecutedRemotely(spawn));

      Platform platform = getPlatform(spawn, remoteOptions);
      if (platform != null) {
        builder.setPlatform(platform);
      }
      if (result.status() != SpawnResult.Status.SUCCESS) {
        builder.setStatus(result.status().toString());
      }
      if (!timeout.isZero()) {
        builder.setTimeoutMillis(timeout.toMillis());
      }
      builder.setCacheable(Spawns.mayBeCached(spawn));
      builder.setRemoteCacheable(Spawns.mayBeCachedRemotely(spawn));
      builder.setExitCode(result.exitCode());
      builder.setCacheHit(result.isCacheHit());
      builder.setRunner(result.getRunnerName());

      if (result.getDigest() != null) {
        builder.setDigest(result.getDigest());
      }

      builder.setMnemonic(spawn.getMnemonic());

      if (spawn.getTargetLabel() != null) {
        builder.setTargetLabel(spawn.getTargetLabel().toString());
      }

      builder.setMetrics(getSpawnMetricsProto(result));

      try (SilentCloseable c1 = Profiler.instance().profile("logSpawn/write")) {
        rawOutputStream.write(builder.build());
      }
    }
  }

  @Override
  public void close() throws IOException {
    rawOutputStream.close();

    if (!needsConversion()) {
      return;
    }

    try (MessageInputStream<SpawnExec> rawInputStream =
            new BinaryInputStreamWrapper<>(
                tempPath.getInputStream(), SpawnExec.getDefaultInstance());
        MessageOutputStream<SpawnExec> convertedOutputStream =
            getConvertedOutputStream(outputPath)) {
      if (sorted) {
        StableSort.stableSort(rawInputStream, convertedOutputStream);
      } else {
        SpawnExec ex;
        while ((ex = rawInputStream.read()) != null) {
          convertedOutputStream.write(ex);
        }
      }
    } finally {
      try {
        tempPath.delete();
      } catch (IOException e) {
        // Intentionally ignored.
      }
    }
  }

  /**
   * Expands a directory into its contents.
   *
   * <p>Note the difference between {@code displayPath} and {@code contentPath}: the first is where
   * the spawn can find the directory, while the second is where Bazel can find it. They're not the
   * same for a directory appearing in a runfiles or fileset tree.
   */
  private void listDirectoryContents(
      PathFragment displayPath,
      Path contentPath,
      Consumer<File> addFile,
      InputMetadataProvider inputMetadataProvider,
      boolean isTool)
      throws IOException {
    List<Dirent> sortedDirent = new ArrayList<>(contentPath.readdir(Symlinks.NOFOLLOW));
    sortedDirent.sort(Comparator.comparing(Dirent::getName));

    for (Dirent dirent : sortedDirent) {
      String name = dirent.getName();
      PathFragment childDisplayPath = displayPath.getChild(name);
      Path childContentPath = contentPath.getChild(name);

      if (dirent.getType() == Dirent.Type.DIRECTORY) {
        listDirectoryContents(
            childDisplayPath, childContentPath, addFile, inputMetadataProvider, isTool);
        continue;
      }

      addFile.accept(
          File.newBuilder()
              .setPath(childDisplayPath.getPathString())
              .setDigest(
                  computeDigest(
                      null,
                      childContentPath,
                      inputMetadataProvider,
                      xattrProvider,
                      digestHashFunction,
                      /* includeHashFunctionName= */ true))
              .setIsTool(isTool)
              .build());
    }
  }
}
