// Copyright 2019 Google Inc. 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 terminal

import (
	"fmt"
	"io"
	"os"
	"os/signal"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"android/soong/ui/status"
)

const tableHeightEnVar = "SOONG_UI_TABLE_HEIGHT"

type actionTableEntry struct {
	action    *status.Action
	startTime time.Time
}

type smartStatusOutput struct {
	writer    io.Writer
	formatter formatter

	lock sync.Mutex

	haveBlankLine bool

	tableMode             bool
	tableHeight           int
	requestedTableHeight  int
	termWidth, termHeight int

	runningActions  []actionTableEntry
	ticker          *time.Ticker
	done            chan bool
	sigwinch        chan os.Signal
	sigwinchHandled chan bool
}

// NewSmartStatusOutput returns a StatusOutput that represents the
// current build status similarly to Ninja's built-in terminal
// output.
func NewSmartStatusOutput(w io.Writer, formatter formatter) status.StatusOutput {
	s := &smartStatusOutput{
		writer:    w,
		formatter: formatter,

		haveBlankLine: true,

		tableMode: true,

		done:     make(chan bool),
		sigwinch: make(chan os.Signal),
	}

	if env, ok := os.LookupEnv(tableHeightEnVar); ok {
		h, _ := strconv.Atoi(env)
		s.tableMode = h > 0
		s.requestedTableHeight = h
	}

	if w, h, ok := termSize(s.writer); ok {
		s.termWidth, s.termHeight = w, h
		s.computeTableHeight()
	} else {
		s.tableMode = false
	}

	if s.tableMode {
		// Add empty lines at the bottom of the screen to scroll back the existing history
		// and make room for the action table.
		// TODO: read the cursor position to see if the empty lines are necessary?
		for i := 0; i < s.tableHeight; i++ {
			fmt.Fprintln(w)
		}

		// Hide the cursor to prevent seeing it bouncing around
		fmt.Fprintf(s.writer, ansi.hideCursor())

		// Configure the empty action table
		s.actionTable()

		// Start a tick to update the action table periodically
		s.startActionTableTick()
	}

	s.startSigwinch()

	return s
}

func (s *smartStatusOutput) Message(level status.MsgLevel, message string) {
	if level < status.StatusLvl {
		return
	}

	str := s.formatter.message(level, message)

	s.lock.Lock()
	defer s.lock.Unlock()

	if level > status.StatusLvl {
		s.print(str)
	} else {
		s.statusLine(str)
	}
}

func (s *smartStatusOutput) StartAction(action *status.Action, counts status.Counts) {
	startTime := time.Now()

	str := action.Description
	if str == "" {
		str = action.Command
	}

	progress := s.formatter.progress(counts)

	s.lock.Lock()
	defer s.lock.Unlock()

	s.runningActions = append(s.runningActions, actionTableEntry{
		action:    action,
		startTime: startTime,
	})

	s.statusLine(progress + str)
}

func (s *smartStatusOutput) FinishAction(result status.ActionResult, counts status.Counts) {
	str := result.Description
	if str == "" {
		str = result.Command
	}

	progress := s.formatter.progress(counts) + str

	output := s.formatter.result(result)

	s.lock.Lock()
	defer s.lock.Unlock()

	for i, runningAction := range s.runningActions {
		if runningAction.action == result.Action {
			s.runningActions = append(s.runningActions[:i], s.runningActions[i+1:]...)
			break
		}
	}

	if output != "" {
		s.statusLine(progress)
		s.requestLine()
		s.print(output)
	} else {
		s.statusLine(progress)
	}
}

func (s *smartStatusOutput) Flush() {
	if s.tableMode {
		// Stop the action table tick outside of the lock to avoid lock ordering issues between s.done and
		// s.lock, the goroutine in startActionTableTick can get blocked on the lock and be unable to read
		// from the channel.
		s.stopActionTableTick()
	}

	s.lock.Lock()
	defer s.lock.Unlock()

	s.stopSigwinch()

	s.requestLine()

	s.runningActions = nil

	if s.tableMode {
		// Update the table after clearing runningActions to clear it
		s.actionTable()

		// Reset the scrolling region to the whole terminal
		fmt.Fprintf(s.writer, ansi.resetScrollingMargins())
		_, height, _ := termSize(s.writer)
		// Move the cursor to the top of the now-blank, previously non-scrolling region
		fmt.Fprintf(s.writer, ansi.setCursor(height-s.tableHeight, 1))
		// Turn the cursor back on
		fmt.Fprintf(s.writer, ansi.showCursor())
	}
}

func (s *smartStatusOutput) Write(p []byte) (int, error) {
	s.lock.Lock()
	defer s.lock.Unlock()
	s.print(string(p))
	return len(p), nil
}

func (s *smartStatusOutput) requestLine() {
	if !s.haveBlankLine {
		fmt.Fprintln(s.writer)
		s.haveBlankLine = true
	}
}

func (s *smartStatusOutput) print(str string) {
	if !s.haveBlankLine {
		fmt.Fprint(s.writer, "\r", ansi.clearToEndOfLine())
		s.haveBlankLine = true
	}
	fmt.Fprint(s.writer, str)
	if len(str) == 0 || str[len(str)-1] != '\n' {
		fmt.Fprint(s.writer, "\n")
	}
}

func (s *smartStatusOutput) statusLine(str string) {
	idx := strings.IndexRune(str, '\n')
	if idx != -1 {
		str = str[0:idx]
	}

	// Limit line width to the terminal width, otherwise we'll wrap onto
	// another line and we won't delete the previous line.
	str = elide(str, s.termWidth)

	// Move to the beginning on the line, turn on bold, print the output,
	// turn off bold, then clear the rest of the line.
	start := "\r" + ansi.bold()
	end := ansi.regular() + ansi.clearToEndOfLine()
	fmt.Fprint(s.writer, start, str, end)
	s.haveBlankLine = false
}

func elide(str string, width int) string {
	if width > 0 && len(str) > width {
		// TODO: Just do a max. Ninja elides the middle, but that's
		// more complicated and these lines aren't that important.
		str = str[:width]
	}

	return str
}

func (s *smartStatusOutput) startActionTableTick() {
	s.ticker = time.NewTicker(time.Second)
	go func() {
		for {
			select {
			case <-s.ticker.C:
				s.lock.Lock()
				s.actionTable()
				s.lock.Unlock()
			case <-s.done:
				return
			}
		}
	}()
}

func (s *smartStatusOutput) stopActionTableTick() {
	s.ticker.Stop()
	s.done <- true
}

func (s *smartStatusOutput) startSigwinch() {
	signal.Notify(s.sigwinch, syscall.SIGWINCH)
	go func() {
		for _ = range s.sigwinch {
			s.lock.Lock()
			s.updateTermSize()
			if s.tableMode {
				s.actionTable()
			}
			s.lock.Unlock()
			if s.sigwinchHandled != nil {
				s.sigwinchHandled <- true
			}
		}
	}()
}

func (s *smartStatusOutput) stopSigwinch() {
	signal.Stop(s.sigwinch)
	close(s.sigwinch)
}

// computeTableHeight recomputes s.tableHeight based on s.termHeight and s.requestedTableHeight.
func (s *smartStatusOutput) computeTableHeight() {
	tableHeight := s.requestedTableHeight
	if tableHeight == 0 {
		tableHeight = s.termHeight / 4
		if tableHeight < 1 {
			tableHeight = 1
		} else if tableHeight > 10 {
			tableHeight = 10
		}
	}
	if tableHeight > s.termHeight-1 {
		tableHeight = s.termHeight - 1
	}
	s.tableHeight = tableHeight
}

// updateTermSize recomputes the table height after a SIGWINCH and pans any existing text if
// necessary.
func (s *smartStatusOutput) updateTermSize() {
	if w, h, ok := termSize(s.writer); ok {
		oldScrollingHeight := s.termHeight - s.tableHeight

		s.termWidth, s.termHeight = w, h

		if s.tableMode {
			s.computeTableHeight()

			scrollingHeight := s.termHeight - s.tableHeight

			// If the scrolling region has changed, attempt to pan the existing text so that it is
			// not overwritten by the table.
			if scrollingHeight < oldScrollingHeight {
				pan := oldScrollingHeight - scrollingHeight
				if pan > s.tableHeight {
					pan = s.tableHeight
				}
				fmt.Fprint(s.writer, ansi.panDown(pan))
			}
		}
	}
}

func (s *smartStatusOutput) actionTable() {
	scrollingHeight := s.termHeight - s.tableHeight

	// Update the scrolling region in case the height of the terminal changed

	fmt.Fprint(s.writer, ansi.setScrollingMargins(1, scrollingHeight))

	// Write as many status lines as fit in the table
	for tableLine := 0; tableLine < s.tableHeight; tableLine++ {
		if tableLine >= s.tableHeight {
			break
		}
		// Move the cursor to the correct line of the non-scrolling region
		fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight+1+tableLine, 1))

		if tableLine < len(s.runningActions) {
			runningAction := s.runningActions[tableLine]

			seconds := int(time.Since(runningAction.startTime).Round(time.Second).Seconds())

			desc := runningAction.action.Description
			if desc == "" {
				desc = runningAction.action.Command
			}

			color := ""
			if seconds >= 60 {
				color = ansi.red() + ansi.bold()
			} else if seconds >= 30 {
				color = ansi.yellow() + ansi.bold()
			}

			durationStr := fmt.Sprintf("   %2d:%02d ", seconds/60, seconds%60)
			desc = elide(desc, s.termWidth-len(durationStr))
			durationStr = color + durationStr + ansi.regular()
			fmt.Fprint(s.writer, durationStr, desc)
		}
		fmt.Fprint(s.writer, ansi.clearToEndOfLine())
	}

	// Move the cursor back to the last line of the scrolling region
	fmt.Fprint(s.writer, ansi.setCursor(scrollingHeight, 1))
}

var ansi = ansiImpl{}

type ansiImpl struct{}

func (ansiImpl) clearToEndOfLine() string {
	return "\x1b[K"
}

func (ansiImpl) setCursor(row, column int) string {
	// Direct cursor address
	return fmt.Sprintf("\x1b[%d;%dH", row, column)
}

func (ansiImpl) setScrollingMargins(top, bottom int) string {
	// Set Top and Bottom Margins DECSTBM
	return fmt.Sprintf("\x1b[%d;%dr", top, bottom)
}

func (ansiImpl) resetScrollingMargins() string {
	// Set Top and Bottom Margins DECSTBM
	return fmt.Sprintf("\x1b[r")
}

func (ansiImpl) red() string {
	return "\x1b[31m"
}

func (ansiImpl) yellow() string {
	return "\x1b[33m"
}

func (ansiImpl) bold() string {
	return "\x1b[1m"
}

func (ansiImpl) regular() string {
	return "\x1b[0m"
}

func (ansiImpl) showCursor() string {
	return "\x1b[?25h"
}

func (ansiImpl) hideCursor() string {
	return "\x1b[?25l"
}

func (ansiImpl) panDown(lines int) string {
	return fmt.Sprintf("\x1b[%dS", lines)
}

func (ansiImpl) panUp(lines int) string {
	return fmt.Sprintf("\x1b[%dT", lines)
}
