package querylog

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"os"
	"strings"
	"sync"
	"time"

	"github.com/AdguardTeam/AdGuardHome/internal/aghos"
	"github.com/AdguardTeam/golibs/errors"
	"github.com/AdguardTeam/golibs/logutil/slogutil"
)

const (
	// Timestamp not found errors.
	errTSNotFound errors.Error = "ts not found"
	errTSTooLate  errors.Error = "ts too late"
	errTSTooEarly errors.Error = "ts too early"

	// maxEntrySize is a maximum size of the entry.
	//
	// TODO: Find a way to grow buffer instead of relying on this value when
	// reading strings.
	maxEntrySize = 16 * 1024

	// bufferSize should be enough for at least this number of entries.
	bufferSize = 100 * maxEntrySize
)

// qLogFile represents a single query log file.  It allows reading from the
// file in the reverse order.
//
// Please note, that this is a stateful object.  Internally, it contains a
// pointer to a specific position in the file, and it reads lines in reverse
// order starting from that position.
type qLogFile struct {
	// file is the query log file.
	file *os.File

	// buffer that we've read from the file.
	buffer []byte

	// lock is a mutex to make it thread-safe.
	lock sync.Mutex

	// position is the position in the file.
	position int64

	// bufferStart is the start of the buffer (in the file).
	bufferStart int64

	// bufferLen is the length of the buffer.
	bufferLen int
}

// newQLogFile initializes a new instance of the qLogFile.
func newQLogFile(path string) (qf *qLogFile, err error) {
	f, err := os.OpenFile(path, os.O_RDONLY, aghos.DefaultPermFile)
	if err != nil {
		return nil, err
	}

	return &qLogFile{file: f}, nil
}

// validateQLogLineIdx returns error if the line index is not valid to continue
// search.
func (q *qLogFile) validateQLogLineIdx(lineIdx, lastProbeLineIdx, ts, fSize int64) (err error) {
	switch lineIdx {
	case lastProbeLineIdx:
		if lineIdx == 0 {
			return errTSTooEarly
		}

		// If we're testing the same line twice then most likely the scope is
		// too narrow and we won't find anything anymore in any other file.
		return fmt.Errorf("looking up timestamp %d in %q: %w", ts, q.file.Name(), errTSNotFound)
	case fSize:
		return errTSTooLate
	default:
		return nil
	}
}

// seekTS performs binary search in the query log file looking for a record
// with the specified timestamp.  Once the record is found, it sets "position"
// so that the next ReadNext call returned that record.
//
// The algorithm is rather simple:
//  1. It starts with the position in the middle of a file.
//  2. Shifts back to the beginning of the line.
//  3. Checks the log record timestamp.
//  4. If it is lower than the timestamp we are looking for, it shifts seek
//     position to 3/4 of the file. Otherwise, to 1/4 of the file.
//  5. It performs the search again, every time the search scope is narrowed
//     twice.
//
// Returns:
//   - It returns the position of the line with the timestamp we were looking
//     for so that when we call "ReadNext" this line was returned.
//   - Depth of the search (how many times we compared timestamps).
//   - If we could not find it, it returns one of the errors described above.
func (q *qLogFile) seekTS(
	ctx context.Context,
	logger *slog.Logger,
	timestamp int64,
) (pos int64, depth int, err error) {
	q.lock.Lock()
	defer q.lock.Unlock()

	// Empty the buffer.
	q.buffer = nil

	// First of all, check the file size.
	fileInfo, err := q.file.Stat()
	if err != nil {
		return 0, 0, err
	}

	fileSize := fileInfo.Size()
	st := &tsSearchState{
		start:            0,
		end:              fileSize,
		probe:            fileSize / 2,
		lastProbeLineIdx: -1,
		fileSize:         fileSize,
	}

	// Count seek depth in order to detect mistakes.  If depth is too large,
	// we should stop the search.
	for {
		var found bool
		found, err = q.seekTSStep(ctx, logger, timestamp, st)
		if err != nil {
			return 0, st.depth, err
		}

		if found {
			break
		}
	}

	q.position = st.lineIdx + int64(len(st.line))

	return q.position, st.depth, nil
}

// tsSearchState contains the state of the binary-search loop.
type tsSearchState struct {
	// line holds the contents of the matched line.
	line string

	// end is the end of the current search interval.
	end int64

	// fileSize is the total file size in bytes.
	fileSize int64

	// lastProbeLineIdx is the byte offset of the last probed line start.
	lastProbeLineIdx int64

	// lineIdx is the byte offset of the current line start.
	lineIdx int64

	// probe is the byte offset to probe.
	probe int64

	// start is the start of current search interval.
	start int64

	// depth is the number of search iterations performed.
	depth int
}

// maxSearchDepth is the maximum number of search iterations.
const maxSearchDepth = 100

// seekTSStep performs one iteration of the binary search over the qlog file.  l
// and st must not be nil.
func (q *qLogFile) seekTSStep(
	ctx context.Context,
	l *slog.Logger,
	timestamp int64,
	st *tsSearchState,
) (found bool, err error) {
	line, lineIdx, lineEndIdx, err := q.readAndValidateProbe(st, timestamp)
	if err != nil {
		// Don't wrap the error, because it's informative enough as is.
		return false, err
	}

	// Get the timestamp from the query log record.
	ts := readQLogTimestamp(ctx, l, line)
	if ts == 0 {
		return false, fmt.Errorf(
			"looking up timestamp %d in %q: record %q has empty timestamp",
			timestamp,
			q.file.Name(),
			line,
		)
	}

	if ts == timestamp {
		// Found the target record.
		st.line = line
		st.lineIdx = lineIdx

		return true, nil
	}

	// Narrow the scope and repeat the search.
	if ts > timestamp {
		// If the timestamp we're looking for is OLDER than what we found, then
		// the line is somewhere on the LEFT side from the current probe
		// position.
		st.end = lineIdx
	} else {
		// If the timestamp we're looking for is NEWER than what we found, then
		// the line is somewhere on the RIGHT side from the current probe
		// position.
		st.start = lineEndIdx
	}
	st.probe = st.start + (st.end-st.start)/2

	st.depth++
	if st.depth >= maxSearchDepth {
		return false, fmt.Errorf(
			"looking up timestamp %d in %q: depth %d too high: %w",
			timestamp,
			q.file.Name(),
			st.depth,
			errTSNotFound,
		)
	}

	return false, nil
}

// readAndValidateProbe reads the probe line and validates the line index.
func (q *qLogFile) readAndValidateProbe(
	st *tsSearchState,
	timestamp int64,
) (line string, lineIdx, lineEndIdx int64, err error) {
	// Get the line at the specified position.
	line, lineIdx, lineEndIdx, err = q.readProbeLine(st.probe)
	if err != nil {
		return "", 0, 0, fmt.Errorf("reading probe line: %w", err)
	}

	err = q.validateQLogLineIdx(lineIdx, st.lastProbeLineIdx, timestamp, st.fileSize)
	if err != nil {
		return "", 0, 0, fmt.Errorf("validating line index: %w", err)
	}

	st.lastProbeLineIdx = lineIdx

	return line, lineIdx, lineEndIdx, nil
}

// SeekStart changes the current position to the end of the file.  Please note,
// that we're reading query log in the reverse order and that's why log start
// is actually the end of file.
//
// Returns nil if we were able to change the current position.  Returns error
// in any other case.
func (q *qLogFile) SeekStart() (int64, error) {
	q.lock.Lock()
	defer q.lock.Unlock()

	// Empty the buffer.
	q.buffer = nil

	// First of all, check the file size.
	fileInfo, err := q.file.Stat()
	if err != nil {
		return 0, err
	}

	// Place the position to the very end of file.
	q.position = max(fileInfo.Size()-1, 0)

	return q.position, nil
}

// ReadNext reads the next line (in the reverse order) from the file and shifts
// the current position left to the next (actually prev) line.
//
// Returns io.EOF if there's nothing more to read.
func (q *qLogFile) ReadNext() (string, error) {
	q.lock.Lock()
	defer q.lock.Unlock()

	if q.position == 0 {
		return "", io.EOF
	}

	line, lineIdx, err := q.readNextLine(q.position)
	if err != nil {
		return "", err
	}

	// Shift position.
	if lineIdx == 0 {
		q.position = 0
	} else {
		// There's usually a line break before the line, so we should shift one
		// more char left from the line "\nline".
		q.position = lineIdx - 1
	}
	return line, err
}

// Close frees the underlying resources.
func (q *qLogFile) Close() error {
	return q.file.Close()
}

// readNextLine reads the next line from the specified position.  This line
// actually have to END on that position.
//
// The algorithm is:
//  1. Check if we have the buffer initialized.
//  2. If it is so, scan it and look for the line there.
//  3. If we cannot find the line there, read the prev chunk into the buffer.
//  4. Read the line from the buffer.
func (q *qLogFile) readNextLine(position int64) (string, int64, error) {
	relativePos := position - q.bufferStart
	if q.buffer == nil || (relativePos < maxEntrySize && q.bufferStart != 0) {
		// Time to re-init the buffer.
		err := q.initBuffer(position)
		if err != nil {
			return "", 0, err
		}
		relativePos = position - q.bufferStart
	}

	// Look for the end of the prev line, this is where we'll read from.
	startLine := int64(0)
	for i := relativePos - 1; i >= 0; i-- {
		if q.buffer[i] == '\n' {
			startLine = i + 1
			break
		}
	}

	line := string(q.buffer[startLine:relativePos])
	lineIdx := q.bufferStart + startLine

	return line, lineIdx, nil
}

// initBuffer initializes the qLogFile buffer.  The goal is to read a chunk of
// file that includes the line with the specified position.
func (q *qLogFile) initBuffer(position int64) error {
	q.bufferStart = int64(0)
	if position > bufferSize {
		q.bufferStart = position - bufferSize
	}

	// Seek to this position.
	_, err := q.file.Seek(q.bufferStart, io.SeekStart)
	if err != nil {
		return err
	}

	if q.buffer == nil {
		q.buffer = make([]byte, bufferSize)
	}

	q.bufferLen, err = q.file.Read(q.buffer)

	return err
}

// readProbeLine reads a line that includes the specified position.  This
// method is supposed to be used when we use binary search in the Seek method.
// In the case of consecutive reads, use readNext, cause it uses better buffer.
func (q *qLogFile) readProbeLine(position int64) (string, int64, int64, error) {
	// First of all, we should read a buffer that will include the query log
	// line.  In order to do this, we'll define the boundaries.
	seekPosition := int64(0)
	// Position relative to the buffer we're going to read.
	relativePos := position
	if position > maxEntrySize {
		seekPosition = position - maxEntrySize
		relativePos = maxEntrySize
	}

	// Seek to this position.
	_, err := q.file.Seek(seekPosition, io.SeekStart)
	if err != nil {
		return "", 0, 0, err
	}

	// The buffer size is 2*maxEntrySize.
	buffer := make([]byte, maxEntrySize*2)
	bufferLen, err := q.file.Read(buffer)
	if err != nil {
		return "", 0, 0, err
	}

	// Now start looking for the new line character starting from the
	// relativePos and going left.
	startLine := int64(0)
	for i := relativePos - 1; i >= 0; i-- {
		if buffer[i] == '\n' {
			startLine = i + 1
			break
		}
	}
	// Looking for the end of line now.
	endLine := int64(bufferLen)
	lineEndIdx := endLine + seekPosition
	for i := relativePos; i < int64(bufferLen); i++ {
		if buffer[i] == '\n' {
			endLine = i
			lineEndIdx = endLine + seekPosition + 1
			break
		}
	}

	// Finally we can return the string we were looking for.
	lineIdx := startLine + seekPosition
	return string(buffer[startLine:endLine]), lineIdx, lineEndIdx, nil
}

// readJSONValue reads a JSON string in form of '"key":"value"'.  prefix must
// be of the form '"key":"' to generate less garbage.
func readJSONValue(s, prefix string) (res string) {
	i := strings.Index(s, prefix)
	if i == -1 {
		return ""
	}

	start := i + len(prefix)
	i = strings.IndexByte(s[start:], '"')
	if i == -1 {
		return ""
	}

	end := start + i

	return s[start:end]
}

// readJSONNumericValue reads a string containing unquoted JSON numeric value
// from substring of s after prefix.
func readJSONNumericValue(s, prefix string) (res string) {
	i := strings.Index(s, prefix)
	if i == -1 {
		return ""
	}

	start := i + len(prefix)
	i = strings.IndexFunc(s[start:], func(r rune) (ok bool) {
		return (r > '9' || r < '0') && r != '.'
	})
	if i == -1 {
		return ""
	}

	end := start + i

	return s[start:end]
}

// readQLogTimestamp reads the timestamp field from the query log line.  logger
// must not be nil.
func readQLogTimestamp(ctx context.Context, logger *slog.Logger, str string) (res int64) {
	val := readJSONValue(str, `"T":"`)
	if len(val) == 0 {
		val = readJSONValue(str, `"Time":"`)
	}

	if len(val) == 0 {
		logger.ErrorContext(ctx, "couldn't find timestamp", "line", str)

		return 0
	}

	tm, err := time.Parse(time.RFC3339Nano, val)
	if err != nil {
		logger.ErrorContext(ctx, "couldn't parse timestamp", "value", val, slogutil.KeyError, err)

		return 0
	}

	return tm.UnixNano()
}
