// Copyright 2019 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.bazel.rules.ninja.lexer;

import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.devtools.build.lib.bazel.rules.ninja.file.FileFragment;
import com.google.devtools.build.lib.util.Pair;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

/** Ninja files lexer. The types of tokens: {@link NinjaToken}. */
public class NinjaLexer {
  // They all are having different first letter, let's use it.
  private static final ImmutableMap<Byte, NinjaToken> KEYWORD_MAP =
      // There is no #of() method for 6 key-value pairs.
      Stream.of(
              NinjaToken.BUILD,
              NinjaToken.RULE,
              NinjaToken.DEFAULT,
              NinjaToken.SUBNINJA,
              NinjaToken.INCLUDE,
              NinjaToken.POOL)
          .collect(ImmutableMap.toImmutableMap(token -> token.getBytes()[0], nt -> nt));

  private final FileFragment fragment;
  private NinjaLexerStep step;
  private final List<Pair<Integer, Integer>> ranges;
  private final List<NinjaToken> tokens;
  /** Flag to give a hint how letters should be interpreted (as text, identifier, path). */
  private TextKind expectedTextKind = TextKind.IDENTIFIER;

  private boolean interpretPoolAsVariable = false;

  /** @param fragment fragment to do the lexing on */
  public NinjaLexer(FileFragment fragment) {
    this.fragment = fragment;
    step = new NinjaLexerStep(fragment, 0);
    ranges = Lists.newArrayList();
    tokens = Lists.newArrayList();
  }

  /**
   * Returns true if following nextToken() call may produce meaningful token. However, it may happen
   * that nextToken() will only produce {@link NinjaToken#EOF}, {@link NinjaToken#ZERO} or {@link
   * NinjaToken#ERROR}.
   *
   * <p>It is an optimization here to check for 'seen' flags: nextToken() may return some meaningful
   * token, and at the same time already discover the end of file or zero byte.
   */
  public boolean hasNextToken() {
    return step.canAdvance();
  }

  /**
   * Returns {@link NinjaToken} type of the token for the next non-space and non-comment token
   * at/after current <code>position</code> position.
   */
  public NinjaToken nextToken() {
    Preconditions.checkState(step.canAdvance());
    while (step.canAdvance()) {
      // First byte is checked right in constructor.
      if (step.isSeenZero()) {
        return push(NinjaToken.ZERO);
      }
      byte b = step.startByte();
      switch (b) {
        case ' ':
        case '\t':
          step.skipSpaces();
          if (step.getPosition() == 0
              || NinjaToken.NEWLINE.equals(Iterables.getLast(tokens, null))) {
            return push(NinjaToken.INDENT);
          }
          break;
        case '\r':
          expectedTextKind = TextKind.IDENTIFIER;
          step.processLineFeedNewLine();
          return push(NinjaToken.NEWLINE);
        case '\n':
          expectedTextKind = TextKind.IDENTIFIER;
          return push(NinjaToken.NEWLINE);
        case '#':
          step.skipComment();
          break;
        case '=':
          if (TextKind.TEXT.equals(expectedTextKind)) {
            step.readText();
            return push(NinjaToken.TEXT);
          }
          return push(NinjaToken.EQUALS);
        case ':':
          return push(NinjaToken.COLON);
        case '|':
          if (TextKind.TEXT.equals(expectedTextKind)) {
            step.readText();
            return push(NinjaToken.TEXT);
          }
          if (step.tryReadDoublePipe()) {
            return push(NinjaToken.PIPE2);
          }
          if (step.tryReadPipeAt()) {
            return push(NinjaToken.PIPE_AT);
          }
          return push(NinjaToken.PIPE);
        case '$':
          if (step.trySkipEscapedNewline()) {
            break;
          }
          if (step.tryReadVariableInBrackets() || step.tryReadSimpleVariable()) {
            return push(NinjaToken.VARIABLE);
          }
          if (step.tryReadEscapedLiteral()) {
            return push(NinjaToken.ESCAPED_TEXT);
          }
          step.forceError("Bad $-escape (literal $ must be written as $$)");
          return push(NinjaToken.ERROR);
        default:
          switch (expectedTextKind) {
            case TEXT:
              step.readText();
              return push(NinjaToken.TEXT);
            case PATH:
              step.readPath();
              return push(NinjaToken.TEXT);
            case IDENTIFIER:
              step.tryReadIdentifier();
              if (step.getError() == null) {
                byte[] bytes = step.getBytes();
                NinjaToken keywordToken = KEYWORD_MAP.get(bytes[0]);
                if (keywordToken != null
                    && !(interpretPoolAsVariable && NinjaToken.POOL.equals(keywordToken))
                    && Arrays.equals(keywordToken.getBytes(), bytes)) {
                  return push(keywordToken);
                }
              }
              return push(NinjaToken.IDENTIFIER);
          }
          throw new IllegalStateException();
      }
      if (step.canAdvance()) {
        step.ensureEnd();
        // For all skipping cases: move to the next step.
        step = step.nextStep();
      }
    }
    return push(NinjaToken.EOF);
  }

  /** Return the bytes of the token, returned by previous nextToken() call. */
  public byte[] getTokenBytes() {
    if (ranges.isEmpty()) {
      throw new IllegalStateException();
    }
    return fragment.getBytes(getLastStart(), getLastEnd());
  }

  private NinjaToken push(NinjaToken token) {
    step.ensureEnd();
    ranges.add(Pair.of(step.getStart(), step.getEnd()));
    tokens.add(token);
    if (step.getError() != null) {
      // Do not move in case of error.
      return NinjaToken.ERROR;
    }
    if (step.canAdvance()) {
      step = step.nextStep();
    }
    return token;
  }

  public boolean haveReadAnyTokens() {
    return !ranges.isEmpty();
  }

  public int getLastStart() {
    if (ranges.isEmpty()) {
      throw new IllegalStateException();
    }
    return Preconditions.checkNotNull(Iterables.getLast(ranges).getFirst());
  }

  public int getLastEnd() {
    if (ranges.isEmpty()) {
      throw new IllegalStateException();
    }
    return Preconditions.checkNotNull(Iterables.getLast(ranges).getSecond());
  }

  /** Give a hint how letters should be interpreted (as text, identifier, path). */
  public void setExpectedTextKind(TextKind expectedTextKind) {
    this.expectedTextKind = expectedTextKind;
  }

  /** When the keyword 'pool' is met, interpret it as identifier, not as a keyword. */
  public void interpretPoolAsVariable() {
    this.interpretPoolAsVariable = true;
  }

  /** Undo the previously read token. */
  public void undo() {
    Preconditions.checkState(ranges.size() == tokens.size());
    ranges.remove(ranges.size() - 1);
    tokens.remove(tokens.size() - 1);
    step = new NinjaLexerStep(fragment, ranges.isEmpty() ? 0 : getLastEnd());
    expectedTextKind = TextKind.IDENTIFIER;
  }

  public String getError() {
    return step.getError();
  }

  public FileFragment getFragment() {
    return fragment;
  }

  /**
   * Enum with variants of text fragments parsing: as identifier (most restricted set of symbols),
   * path (all spaces should be $-escaped, and | symbol has a special meaning), or text.
   */
  public enum TextKind {
    IDENTIFIER,
    PATH,
    TEXT
  }
}
