package main

import (
	"bufio"
	"context"
	"errors"
	"io"
	"os"
	"time"
)

type TailOptions struct {
	Path          string
	StartAtEnd    bool
	PollInterval  time.Duration
	MaxLineBytes  int
	Emit          func(string)
	Logf          func(string, ...any)
	OnRotateEvent func()
}

// TailFile tails a single file using polling (portable across OSes).
// It handles:
// - file missing (wait + retry)
// - log rotate / truncation (reopen)
// - partial lines (keeps buffer)
// - max line length (prevents runaway memory)
func TailFile(ctx context.Context, opt TailOptions) {
	if opt.PollInterval <= 0 {
		opt.PollInterval = 250 * time.Millisecond
	}
	if opt.MaxLineBytes <= 0 {
		opt.MaxLineBytes = 1024 * 1024
	}
	logf := opt.Logf
	if logf == nil {
		logf = func(string, ...any) {}
	}
	emit := opt.Emit
	if emit == nil {
		emit = func(string) {}
	}
	onRotate := opt.OnRotateEvent
	if onRotate == nil {
		onRotate = func() {}
	}

	var (
		f      *os.File
		reader *bufio.Reader
		offset int64
	)

	open := func(seekEnd bool) error {
		if f != nil {
			_ = f.Close()
			f = nil
			reader = nil
		}
		ff, err := os.Open(opt.Path)
		if err != nil {
			return err
		}
		f = ff
		if seekEnd {
			if off, e := f.Seek(0, io.SeekEnd); e == nil {
				offset = off
			}
		} else {
			offset = 0
		}
		reader = bufio.NewReaderSize(f, 64*1024)
		return nil
	}

	// best effort initial open
	for {
		select {
		case <-ctx.Done():
			if f != nil {
				_ = f.Close()
			}
			return
		default:
		}
		if err := open(opt.StartAtEnd); err != nil {
			if !errors.Is(err, os.ErrNotExist) {
				logf("open %s: %v", opt.Path, err)
			}
			sleepCtx(ctx, opt.PollInterval)
			continue
		}
		break
	}

	var partial []byte

	for {
		select {
		case <-ctx.Done():
			if f != nil {
				_ = f.Close()
			}
			return
		default:
		}

		// Try to read a line (non-blocking-ish: relies on file append, but will return io.EOF when no new data).
		line, err := reader.ReadBytes('\n')
		if err == nil {
			offset += int64(len(line))
			// merge partial + line
			if len(partial) > 0 {
				line = append(partial, line...)
				partial = nil
			}
			emit(trimNewline(line))
			continue
		}

		// Handle EOF: no new data yet OR file rotated/truncated.
		if errors.Is(err, io.EOF) {
			// If we got some bytes without newline, keep as partial (capped).
			buf := reader.Buffered()
			if buf > 0 {
				// Read what's buffered without blocking.
				b, _ := reader.Peek(buf)
				if len(b) > 0 {
					_, _ = reader.Discard(len(b))
					offset += int64(len(b))
					partial = append(partial, b...)
					if len(partial) > opt.MaxLineBytes {
						partial = partial[:0]
						emit("[collector] dropped oversized partial line")
					}
				}
			}

			// Check rotation/truncation by statting the file.
			fi, statErr := os.Stat(opt.Path)
			if statErr != nil {
				// file missing (rotate rename + recreate) or permission issues
				sleepCtx(ctx, opt.PollInterval)
				// try reopen when it appears
				_ = open(false)
				continue
			}
			size := fi.Size()
			if size < offset {
				// Truncated / rotated -> reopen from beginning.
				onRotate()
				logf("rotate/truncate detected: %s (size %d < offset %d), reopening", opt.Path, size, offset)
				_ = open(false)
				sleepCtx(ctx, 50*time.Millisecond)
				continue
			}

			sleepCtx(ctx, opt.PollInterval)
			continue
		}

		// Other errors: reopen with delay.
		logf("read error %s: %v", opt.Path, err)
		sleepCtx(ctx, opt.PollInterval)
		_ = open(false)
	}
}

func trimNewline(b []byte) string {
	// remove \n and optional \r
	n := len(b)
	for n > 0 && (b[n-1] == '\n' || b[n-1] == '\r') {
		n--
	}
	return string(b[:n])
}

func sleepCtx(ctx context.Context, d time.Duration) {
	if d <= 0 {
		return
	}
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-ctx.Done():
	case <-t.C:
	}
}
