// Copyright 2017 Google Inc.
//
// 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 main

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
)

// grep checks whether the given file's contents match the pattern.
func grep(pattern string, file string) (bool, error) {
	input, err := os.OpenFile(file, os.O_RDONLY, 0)
	if err != nil {
		return false, fmt.Errorf("failed to open %s for read: %v", file, err)
	}
	defer input.Close()

	matched, err := regexp.MatchReader(pattern, bufio.NewReader(input))
	if err != nil {
		return false, fmt.Errorf("failed to search for %s in %s: %v", pattern, file, err)
	}

	return matched, nil
}

// checkLicense checks if the given file contains the necessary license information and returns an
// error if this is not true or if the check cannot be performed.
func checkLicense(workspaceDir string, file string) error {
	for _, pattern := range []string{
		`Copyright.*Google`,
		`Apache License.*2.0`,
	} {
		matched, err := grep(pattern, file)
		if err != nil {
			return fmt.Errorf("license check failed for %s: %v", file, err)
		}
		if !matched {
			return fmt.Errorf("license check failed for %s: %s not found", file, pattern)
		}
	}

	return nil
}

// checkLineLength checks if the given file contains any lines longer than the given maximum and, if
// it does, returns an error.
func checkLineLength(workspaceDir string, file string, max int) error {
	input, err := os.OpenFile(file, os.O_RDONLY, 0)
	if err != nil {
		return fmt.Errorf("failed to open %s for read: %v", file, err)
	}
	defer input.Close()

	reader := bufio.NewReader(input)
	lineNo := 1
	done := false
	for !done {
		line, err := reader.ReadString('\n')
		if err == io.EOF {
			done = true
			// Fall through to process the last line in case it's not empty (when the
			// file didn't end with a newline).
		} else if err != nil {
			return fmt.Errorf("line length check failed for %s: %v", file, err)
		}
		line = strings.TrimRight(line, "\n\r")
		if len(line) > max {
			return fmt.Errorf("line length check failed for %s: line %d exceeds length %d with %d characters", file, lineNo, max, len(line))
		}
		lineNo++
	}

	return nil
}

// checkNoTabs checks if the given file contains any tabs as indentation and, if it does, returns
// an error.
func checkNoTabs(workspaceDir string, file string) error {
	input, err := os.OpenFile(file, os.O_RDONLY, 0)
	if err != nil {
		return fmt.Errorf("failed to open %s for read: %v", file, err)
	}
	defer input.Close()

	preg := regexp.MustCompile(`^ *\t`)

	reader := bufio.NewReader(input)
	lineNo := 1
	done := false
	for !done {
		line, err := reader.ReadString('\n')
		if err == io.EOF {
			done = true
			// Fall through to process the last line in case it's not empty (when the
			// file didn't end with a newline).
		} else if err != nil {
			return fmt.Errorf("no tabs check failed for %s: %v", file, err)
		}
		if preg.MatchString(line) {
			return fmt.Errorf("no tabs check failed for %s: indentation tabs found at line %d", file, lineNo)
		}
		lineNo++
	}

	return nil
}

// captureErrorsFromStdout configures the given non-started "cmd" to save its stdout into "output"
// and to print stderr to this process' stderr.
func captureErrorsFromStdout(cmd *exec.Cmd, output *bytes.Buffer) {
	cmd.Stdout = output
	cmd.Stderr = os.Stderr
}

// captureErrorsFromStderr configures the given non-started "cmd" to save its stderr into "output"
// and to silence its stdout.
func captureErrorsFromStderr(cmd *exec.Cmd, output *bytes.Buffer) {
	cmd.Stdout = nil
	cmd.Stderr = output
}

// runLinter runs a "linting" helper binary that prints diagnostics to some output and whose exit
// status is always true.  "captureErrors" takes a lambda to configure the command to save its
// diagnostics to the given buffer, and is used to account for tools that print messages to either
// stdout or stderr.  The remaining arguments indicate the full command line to run, including the
// path to the tool as the first argument.  The file to check is expected to appear as the last
// argument.
func runLinter(captureErrors func(*exec.Cmd, *bytes.Buffer), toolName string, arg ...string) error {
	file := arg[len(arg)-1]

	var output bytes.Buffer
	cmd := exec.Command(toolName, arg...)
	captureErrors(cmd, &output)
	err := cmd.Run()
	if err != nil {
		return fmt.Errorf("%s check failed for %s: %v", toolName, file, err)
	}
	if output.Len() > 0 {
		fmt.Printf("%s does not pass %s:\n", file, toolName)
		fmt.Println(output.String())
		return fmt.Errorf("%s check failed for %s: not compliant", toolName, file)
	}
	return nil
}

// checkGoFmt checks if the given file is formatted according to gofmt and, if not, prints a diff
// detailing what's wrong with the file to stdout and returns an error.
func checkGofmt(workspaceDir string, file string) error {
	return runLinter(captureErrorsFromStdout, "gofmt", "-d", "-e", "-s", file)
}

// checkGoLint checks if the given file passes golint checks and, if not, prints diagnostic messages
// to stdout and returns an error.
func checkGolint(workspaceDir string, file string) error {
	// Lower confidence levels raise a per-file warning to remind about having a package-level
	// docstring... but the warning is issued blindly without checking for the existing of this
	// docstring in other packages.
	minConfidenceFlag := "-min_confidence=0.3"

	return runLinter(captureErrorsFromStdout, "golint", minConfidenceFlag, file)
}

// checkManpage checks if the given manual page contains any formatting errors by attempting to
// render it.  The output of the rendering is ignored and any errors are printed to stdout,
// returning an error.
func checkManpage(workspaceDir string, file string) error {
	return runLinter(captureErrorsFromStderr, "man", file)
}

// checkAll runs all possible checks on a file.  Returns true if all checks pass, and false
// otherwise.  Error details are dumped to stderr.
func checkAll(workspaceDir string, file string) bool {
	isBuildFile := filepath.Base(file) == "Makefile.in"

	// If a file starts with an upper-case letter, assume it's supporting package documentation
	// (all those files in the root directory) and avoid linting it.
	isDocumentation := mustMatch(`^[A-Z]`, filepath.Base(file)) && !isBuildFile

	log.Printf("Linting file %s", file)
	ok := true

	runCheck := func(checker func(string, string) error, file string) {
		if err := checker(workspaceDir, file); err != nil {
			fmt.Fprintf(os.Stderr, "%s: %v\n", file, err)
			ok = false
		}
	}

	checkLineLength80 := func(workspaceDir string, file string) error {
		return checkLineLength(workspaceDir, file, 80)
	}
	checkLineLength100 := func(workspaceDir string, file string) error {
		return checkLineLength(workspaceDir, file, 100)
	}

	if !isDocumentation && filepath.Base(file) != "settings.json.in" {
		runCheck(checkLicense, file)
	}

	if filepath.Ext(file) == ".go" {
		runCheck(checkGofmt, file)
		runCheck(checkGolint, file)
	} else if filepath.Ext(file) == ".rs" {
		runCheck(checkNoTabs, file)
		runCheck(checkLineLength100, file)
	} else if mustMatch("^\\.[0-9]$", filepath.Ext(file)) {
		runCheck(checkManpage, file)
		runCheck(checkLineLength80, file)
	} else if !isBuildFile {
		runCheck(checkNoTabs, file)
		runCheck(checkLineLength80, file)
	}

	return ok
}

// mustMatch returns true if the given regular expression matches the string.  The regular
// expression is assumed to be valid.
func mustMatch(pattern string, str string) bool {
	matched, err := regexp.MatchString(pattern, str)
	if err != nil {
		panic("invalid regexp")
	}
	return matched
}
