package eventbus

import (
	"context"
	"encoding/binary"
	"encoding/json"
	"errors"
	"fmt"
	"hash/crc32"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

const (
	outboxWALMagic           = uint32(0x4c4e5731) // LNW1
	outboxWALHeaderSize      = 12
	outboxWALMaxRecordBytes  = 16 << 20
	defaultWALSegmentBytes   = 16 << 20
	defaultWALAppendQueue    = 100_000
	defaultWALCommitBatch    = 256
	defaultWALCommitInterval = time.Millisecond
	defaultWALSegmentAge     = 5 * time.Second
)

var ErrDurableOutboxWALClosed = errors.New("durable outbox wal is closed")

type durableOutboxWALConfig struct {
	Directory      string
	SyncWrites     bool
	SegmentBytes   int64
	SegmentAge     time.Duration
	AppendQueue    int
	CommitBatch    int
	CommitInterval time.Duration
}

type outboxWALRecordRef struct {
	segmentID uint64
	index     int
}

type storedOutboxRecord struct {
	Ref    outboxWALRecordRef
	Record durableRecord
}

type outboxWALRecordStatus uint8

const (
	outboxWALPending outboxWALRecordStatus = iota
	outboxWALClaimed
	outboxWALAcknowledged
)

type outboxWALRecordState struct {
	payloadOffset int64
	payloadLength uint32
	status        outboxWALRecordStatus
}

type outboxWALSegment struct {
	id        uint64
	path      string
	createdAt time.Time
	size      int64
	closed    bool
	deleting  bool
	acked     int
	records   []*outboxWALRecordState
}

type outboxWALAppendRequest struct {
	record  durableRecord
	payload []byte
	frame   []byte
	result  chan outboxWALAppendResult
}

type outboxWALAppendResult struct {
	stored storedOutboxRecord
	err    error
}

type durableOutboxWAL struct {
	dir            string
	syncWrites     bool
	segmentBytes   int64
	segmentAge     time.Duration
	commitBatch    int
	commitInterval time.Duration

	mu          sync.Mutex
	segments    []*outboxWALSegment
	segmentByID map[uint64]*outboxWALSegment
	current     *outboxWALSegment
	currentFile *os.File
	backlog     int
	fatalErr    error

	appendMu sync.RWMutex
	closed   bool
	queue    chan outboxWALAppendRequest
	writerWG sync.WaitGroup
	closeOne sync.Once
}

func newDurableOutboxWAL(cfg durableOutboxWALConfig) (*durableOutboxWAL, error) {
	dir := strings.TrimSpace(cfg.Directory)
	if dir == "" {
		return nil, errors.New("durable outbox wal directory is required")
	}
	if cfg.SegmentBytes <= 0 {
		cfg.SegmentBytes = defaultWALSegmentBytes
	}
	if cfg.SegmentAge <= 0 {
		cfg.SegmentAge = defaultWALSegmentAge
	}
	if cfg.AppendQueue <= 0 {
		cfg.AppendQueue = defaultWALAppendQueue
	}
	if cfg.CommitBatch <= 0 {
		cfg.CommitBatch = defaultWALCommitBatch
	}
	if cfg.CommitInterval <= 0 {
		cfg.CommitInterval = defaultWALCommitInterval
	}
	if err := os.MkdirAll(dir, 0o750); err != nil {
		return nil, fmt.Errorf("create durable outbox wal directory: %w", err)
	}
	w := &durableOutboxWAL{
		dir:            dir,
		syncWrites:     cfg.SyncWrites,
		segmentBytes:   cfg.SegmentBytes,
		segmentAge:     cfg.SegmentAge,
		commitBatch:    cfg.CommitBatch,
		commitInterval: cfg.CommitInterval,
		segmentByID:    map[uint64]*outboxWALSegment{},
		queue:          make(chan outboxWALAppendRequest, cfg.AppendQueue),
	}
	if err := w.loadSegments(); err != nil {
		return nil, err
	}
	if err := w.createCurrentSegment(); err != nil {
		return nil, err
	}
	w.writerWG.Add(1)
	go w.appendLoop()
	return w, nil
}

func (w *durableOutboxWAL) Append(ctx context.Context, record durableRecord) (storedOutboxRecord, error) {
	payload, err := json.Marshal(record)
	if err != nil {
		return storedOutboxRecord{}, fmt.Errorf("marshal durable outbox wal record: %w", err)
	}
	if len(payload) > outboxWALMaxRecordBytes {
		return storedOutboxRecord{}, fmt.Errorf("durable outbox wal record is %d bytes, max %d", len(payload), outboxWALMaxRecordBytes)
	}
	request := outboxWALAppendRequest{
		record:  record,
		payload: payload,
		frame:   encodeOutboxWALFrame(payload),
		result:  make(chan outboxWALAppendResult, 1),
	}
	if ctx == nil {
		ctx = context.Background()
	}
	w.appendMu.RLock()
	if w.closed {
		w.appendMu.RUnlock()
		return storedOutboxRecord{}, ErrDurableOutboxWALClosed
	}
	select {
	case w.queue <- request:
		w.appendMu.RUnlock()
	case <-ctx.Done():
		w.appendMu.RUnlock()
		return storedOutboxRecord{}, ctx.Err()
	}
	// Once admitted to the WAL queue, wait for the durability result even if
	// the caller context is cancelled. Otherwise a committed record could be
	// left claimed with no publisher responsible for it.
	result := <-request.result
	return result.stored, result.err
}

func (w *durableOutboxWAL) ClaimPending(limit int) ([]storedOutboxRecord, error) {
	if limit <= 0 {
		limit = defaultWALCommitBatch
	}
	w.mu.Lock()
	refs := make([]outboxWALRecordRef, 0, limit)
	for _, segment := range w.segments {
		for index, state := range segment.records {
			if state.status != outboxWALPending {
				continue
			}
			state.status = outboxWALClaimed
			refs = append(refs, outboxWALRecordRef{segmentID: segment.id, index: index})
			if len(refs) >= limit {
				break
			}
		}
		if len(refs) >= limit {
			break
		}
	}
	w.mu.Unlock()
	if len(refs) == 0 {
		return nil, nil
	}
	records, err := w.readClaimedRecords(refs)
	if err != nil {
		for _, ref := range refs {
			w.Release(ref)
		}
		return nil, err
	}
	return records, nil
}

func (w *durableOutboxWAL) Release(ref outboxWALRecordRef) {
	w.mu.Lock()
	defer w.mu.Unlock()
	state := w.recordStateLocked(ref)
	if state != nil && state.status == outboxWALClaimed {
		state.status = outboxWALPending
	}
}

func (w *durableOutboxWAL) Ack(ref outboxWALRecordRef) error {
	w.mu.Lock()
	segment := w.segmentByID[ref.segmentID]
	if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
		w.mu.Unlock()
		return nil
	}
	state := segment.records[ref.index]
	if state.status == outboxWALAcknowledged {
		w.mu.Unlock()
		return nil
	}
	state.status = outboxWALAcknowledged
	segment.acked++
	if w.backlog > 0 {
		w.backlog--
	}
	shouldDelete := segment.closed && segment.acked == len(segment.records) && !segment.deleting
	if shouldDelete {
		segment.deleting = true
	}
	w.mu.Unlock()
	if !shouldDelete {
		return nil
	}
	return w.deleteAcknowledgedSegment(segment)
}

func (w *durableOutboxWAL) Stats() (backlog int, oldest time.Time) {
	w.mu.Lock()
	defer w.mu.Unlock()
	backlog = w.backlog
	if backlog == 0 {
		return backlog, time.Time{}
	}
	for _, segment := range w.segments {
		if segment.acked < len(segment.records) {
			return backlog, segment.createdAt
		}
	}
	return backlog, time.Time{}
}

func (w *durableOutboxWAL) Close() error {
	w.closeOne.Do(func() {
		w.appendMu.Lock()
		w.closed = true
		close(w.queue)
		w.appendMu.Unlock()
		w.writerWG.Wait()
	})
	w.mu.Lock()
	err := w.fatalErr
	w.mu.Unlock()
	return err
}

func (w *durableOutboxWAL) appendLoop() {
	defer w.writerWG.Done()
	maintenanceInterval := minDuration(w.segmentAge/2, time.Second)
	if maintenanceInterval < 10*time.Millisecond {
		maintenanceInterval = 10 * time.Millisecond
	}
	maintenance := time.NewTicker(maintenanceInterval)
	defer maintenance.Stop()
	for {
		select {
		case request, ok := <-w.queue:
			if !ok {
				w.finishWriter()
				return
			}
			batch := w.collectAppendBatch(request)
			w.commitAppendBatch(batch)
		case <-maintenance.C:
			if err := w.rotateIfAged(); err != nil {
				w.setFatal(err)
			}
		}
	}
}

func (w *durableOutboxWAL) collectAppendBatch(first outboxWALAppendRequest) []outboxWALAppendRequest {
	batch := make([]outboxWALAppendRequest, 0, w.commitBatch)
	batch = append(batch, first)
	timer := time.NewTimer(w.commitInterval)
	defer timer.Stop()
	for len(batch) < w.commitBatch {
		select {
		case request, ok := <-w.queue:
			if !ok {
				return batch
			}
			batch = append(batch, request)
		case <-timer.C:
			return batch
		}
	}
	return batch
}

func (w *durableOutboxWAL) commitAppendBatch(batch []outboxWALAppendRequest) {
	if len(batch) == 0 {
		return
	}
	w.mu.Lock()
	fatalErr := w.fatalErr
	w.mu.Unlock()
	if fatalErr != nil {
		w.completeAppendErrors(batch, fatalErr)
		return
	}
	for len(batch) > 0 {
		if err := w.rotateBeforeAppend(len(batch[0].frame)); err != nil {
			w.setFatal(err)
			w.completeAppendErrors(batch, err)
			return
		}
		count := w.batchCountForCurrentSegment(batch)
		if count <= 0 {
			count = 1
		}
		group := batch[:count]
		if err := w.commitAppendGroup(group); err != nil {
			w.setFatal(err)
			w.completeAppendErrors(batch, err)
			return
		}
		batch = batch[count:]
	}
}

func (w *durableOutboxWAL) commitAppendGroup(group []outboxWALAppendRequest) error {
	start := w.current.size
	totalBytes := 0
	for _, request := range group {
		totalBytes += len(request.frame)
	}
	buffer := make([]byte, 0, totalBytes)
	for _, request := range group {
		buffer = append(buffer, request.frame...)
	}
	written, err := w.currentFile.Write(buffer)
	if err != nil || written != len(buffer) {
		if err == nil {
			err = io.ErrShortWrite
		}
		w.rollbackAppend(start)
		return fmt.Errorf("append durable outbox wal: %w", err)
	}
	if w.syncWrites {
		if err := w.currentFile.Sync(); err != nil {
			w.rollbackAppend(start)
			return fmt.Errorf("sync durable outbox wal: %w", err)
		}
	}

	w.mu.Lock()
	segment := w.current
	offset := start
	results := make([]outboxWALAppendResult, 0, len(group))
	for _, request := range group {
		state := &outboxWALRecordState{
			payloadOffset: offset + outboxWALHeaderSize,
			payloadLength: uint32(len(request.payload)),
			status:        outboxWALClaimed,
		}
		index := len(segment.records)
		segment.records = append(segment.records, state)
		w.backlog++
		results = append(results, outboxWALAppendResult{stored: storedOutboxRecord{
			Ref:    outboxWALRecordRef{segmentID: segment.id, index: index},
			Record: request.record,
		}})
		offset += int64(len(request.frame))
	}
	segment.size += int64(len(buffer))
	w.mu.Unlock()
	for index, request := range group {
		request.result <- results[index]
	}
	return nil
}

func (w *durableOutboxWAL) rollbackAppend(size int64) {
	if w.currentFile == nil {
		return
	}
	_ = w.currentFile.Truncate(size)
	if w.syncWrites {
		_ = w.currentFile.Sync()
	}
}

func (w *durableOutboxWAL) rotateBeforeAppend(frameBytes int) error {
	if w.current == nil {
		return w.createCurrentSegment()
	}
	if len(w.current.records) == 0 {
		return nil
	}
	tooLarge := w.current.size+int64(frameBytes) > w.segmentBytes
	tooOld := time.Since(w.current.createdAt) >= w.segmentAge
	if !tooLarge && !tooOld {
		return nil
	}
	return w.rotateCurrentSegment()
}

func (w *durableOutboxWAL) rotateIfAged() error {
	w.mu.Lock()
	current := w.current
	shouldRotate := current != nil && len(current.records) > 0 && time.Since(current.createdAt) >= w.segmentAge
	w.mu.Unlock()
	if !shouldRotate {
		return nil
	}
	return w.rotateCurrentSegment()
}

func (w *durableOutboxWAL) rotateCurrentSegment() error {
	if w.currentFile == nil || w.current == nil {
		return w.createCurrentSegment()
	}
	if w.syncWrites {
		if err := w.currentFile.Sync(); err != nil {
			return fmt.Errorf("sync closing durable outbox wal segment: %w", err)
		}
	}
	if err := w.currentFile.Close(); err != nil {
		return fmt.Errorf("close durable outbox wal segment: %w", err)
	}
	w.mu.Lock()
	old := w.current
	old.closed = true
	w.current = nil
	w.currentFile = nil
	deleteOld := old.acked == len(old.records) && !old.deleting
	if deleteOld {
		old.deleting = true
	}
	w.mu.Unlock()
	if deleteOld {
		if err := w.deleteAcknowledgedSegment(old); err != nil {
			return err
		}
	}
	return w.createCurrentSegment()
}

func (w *durableOutboxWAL) createCurrentSegment() error {
	w.mu.Lock()
	lastID := uint64(0)
	if len(w.segments) > 0 {
		lastID = w.segments[len(w.segments)-1].id
	}
	w.mu.Unlock()
	id := uint64(time.Now().UnixNano())
	if id <= lastID {
		id = lastID + 1
	}
	path := filepath.Join(w.dir, outboxWALFileName(id))
	file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_RDWR|os.O_APPEND, 0o640)
	if err != nil {
		return fmt.Errorf("create durable outbox wal segment: %w", err)
	}
	if w.syncWrites {
		if err := syncDirectory(w.dir); err != nil {
			_ = file.Close()
			return fmt.Errorf("sync durable outbox wal directory after create: %w", err)
		}
	}
	segment := &outboxWALSegment{id: id, path: path, createdAt: time.Now()}
	w.mu.Lock()
	w.segments = append(w.segments, segment)
	w.segmentByID[id] = segment
	w.current = segment
	w.currentFile = file
	w.mu.Unlock()
	return nil
}

func (w *durableOutboxWAL) finishWriter() {
	if w.currentFile == nil || w.current == nil {
		return
	}
	var finishErr error
	if w.syncWrites {
		finishErr = w.currentFile.Sync()
	}
	if closeErr := w.currentFile.Close(); finishErr == nil {
		finishErr = closeErr
	}
	w.mu.Lock()
	current := w.current
	current.closed = true
	w.current = nil
	w.currentFile = nil
	deleteCurrent := current.acked == len(current.records) && !current.deleting
	if deleteCurrent {
		current.deleting = true
	}
	w.mu.Unlock()
	if deleteCurrent {
		if err := w.deleteAcknowledgedSegment(current); finishErr == nil {
			finishErr = err
		}
	}
	if finishErr != nil {
		w.setFatal(finishErr)
	}
}

func (w *durableOutboxWAL) batchCountForCurrentSegment(batch []outboxWALAppendRequest) int {
	remaining := w.segmentBytes - w.current.size
	count := 0
	for _, request := range batch {
		if count > 0 && int64(len(request.frame)) > remaining {
			break
		}
		remaining -= int64(len(request.frame))
		count++
		if remaining <= 0 {
			break
		}
	}
	return count
}

func (w *durableOutboxWAL) completeAppendErrors(batch []outboxWALAppendRequest, err error) {
	for _, request := range batch {
		request.result <- outboxWALAppendResult{err: err}
	}
}

func (w *durableOutboxWAL) readClaimedRecords(refs []outboxWALRecordRef) ([]storedOutboxRecord, error) {
	files := map[uint64]*os.File{}
	defer func() {
		for _, file := range files {
			_ = file.Close()
		}
	}()
	records := make([]storedOutboxRecord, 0, len(refs))
	for _, ref := range refs {
		w.mu.Lock()
		segment := w.segmentByID[ref.segmentID]
		state := w.recordStateLocked(ref)
		w.mu.Unlock()
		if segment == nil || state == nil {
			return nil, fmt.Errorf("durable outbox wal record reference not found: segment=%d index=%d", ref.segmentID, ref.index)
		}
		file := files[segment.id]
		if file == nil {
			opened, err := os.Open(segment.path)
			if err != nil {
				return nil, fmt.Errorf("open durable outbox wal segment for replay: %w", err)
			}
			files[segment.id] = opened
			file = opened
		}
		payload := make([]byte, state.payloadLength)
		if _, err := file.ReadAt(payload, state.payloadOffset); err != nil {
			return nil, fmt.Errorf("read durable outbox wal record: %w", err)
		}
		var record durableRecord
		if err := json.Unmarshal(payload, &record); err != nil {
			return nil, fmt.Errorf("decode durable outbox wal record: %w", err)
		}
		records = append(records, storedOutboxRecord{Ref: ref, Record: record})
	}
	return records, nil
}

func (w *durableOutboxWAL) loadSegments() error {
	paths, err := filepath.Glob(filepath.Join(w.dir, "outbox-*.wal"))
	if err != nil {
		return fmt.Errorf("list durable outbox wal segments: %w", err)
	}
	sort.Strings(paths)
	for _, path := range paths {
		segment, err := loadOutboxWALSegment(path, w.syncWrites)
		if err != nil {
			return err
		}
		if len(segment.records) == 0 {
			if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
				return fmt.Errorf("remove empty durable outbox wal segment: %w", err)
			}
			if w.syncWrites {
				if err := syncDirectory(w.dir); err != nil {
					return fmt.Errorf("sync durable outbox wal directory after removing empty segment: %w", err)
				}
			}
			continue
		}
		w.segments = append(w.segments, segment)
		w.segmentByID[segment.id] = segment
		w.backlog += len(segment.records)
	}
	return nil
}

func loadOutboxWALSegment(path string, syncWrites bool) (*outboxWALSegment, error) {
	id, err := parseOutboxWALFileName(filepath.Base(path))
	if err != nil {
		return nil, err
	}
	file, err := os.OpenFile(path, os.O_RDWR, 0)
	if err != nil {
		return nil, fmt.Errorf("open durable outbox wal segment: %w", err)
	}
	defer file.Close()
	info, err := file.Stat()
	if err != nil {
		return nil, fmt.Errorf("stat durable outbox wal segment: %w", err)
	}
	segment := &outboxWALSegment{
		id:        id,
		path:      path,
		createdAt: info.ModTime(),
		closed:    true,
	}
	offset := int64(0)
	header := make([]byte, outboxWALHeaderSize)
	for offset < info.Size() {
		n, readErr := file.ReadAt(header, offset)
		if readErr != nil {
			if readErr == io.EOF && n < outboxWALHeaderSize {
				if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
					return nil, err
				}
				break
			}
			return nil, fmt.Errorf("read durable outbox wal header at %d: %w", offset, readErr)
		}
		if binary.BigEndian.Uint32(header[0:4]) != outboxWALMagic {
			return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid magic", path, offset)
		}
		length := binary.BigEndian.Uint32(header[4:8])
		checksum := binary.BigEndian.Uint32(header[8:12])
		if length == 0 || length > outboxWALMaxRecordBytes {
			return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: invalid length %d", path, offset, length)
		}
		frameEnd := offset + outboxWALHeaderSize + int64(length)
		if frameEnd > info.Size() {
			if err := truncateOutboxWALTail(file, offset, syncWrites); err != nil {
				return nil, err
			}
			break
		}
		payload := make([]byte, length)
		if _, err := file.ReadAt(payload, offset+outboxWALHeaderSize); err != nil {
			return nil, fmt.Errorf("read durable outbox wal payload at %d: %w", offset, err)
		}
		if crc32.ChecksumIEEE(payload) != checksum {
			return nil, fmt.Errorf("durable outbox wal corruption in %s at offset %d: checksum mismatch", path, offset)
		}
		segment.records = append(segment.records, &outboxWALRecordState{
			payloadOffset: offset + outboxWALHeaderSize,
			payloadLength: length,
			status:        outboxWALPending,
		})
		offset = frameEnd
	}
	segment.size = offset
	return segment, nil
}

func truncateOutboxWALTail(file *os.File, size int64, syncWrites bool) error {
	if err := file.Truncate(size); err != nil {
		return fmt.Errorf("truncate incomplete durable outbox wal tail: %w", err)
	}
	if syncWrites {
		if err := file.Sync(); err != nil {
			return fmt.Errorf("sync truncated durable outbox wal tail: %w", err)
		}
	}
	return nil
}

func (w *durableOutboxWAL) deleteAcknowledgedSegment(segment *outboxWALSegment) error {
	err := os.Remove(segment.path)
	if os.IsNotExist(err) {
		err = nil
	}
	if err == nil && w.syncWrites {
		err = syncDirectory(w.dir)
	}
	w.mu.Lock()
	defer w.mu.Unlock()
	if err != nil {
		segment.deleting = false
		return fmt.Errorf("delete acknowledged durable outbox wal segment: %w", err)
	}
	delete(w.segmentByID, segment.id)
	for index, candidate := range w.segments {
		if candidate != segment {
			continue
		}
		w.segments = append(w.segments[:index], w.segments[index+1:]...)
		break
	}
	return nil
}

func (w *durableOutboxWAL) recordStateLocked(ref outboxWALRecordRef) *outboxWALRecordState {
	segment := w.segmentByID[ref.segmentID]
	if segment == nil || ref.index < 0 || ref.index >= len(segment.records) {
		return nil
	}
	return segment.records[ref.index]
}

func (w *durableOutboxWAL) setFatal(err error) {
	if err == nil {
		return
	}
	w.mu.Lock()
	if w.fatalErr == nil {
		w.fatalErr = err
	}
	w.mu.Unlock()
}

func encodeOutboxWALFrame(payload []byte) []byte {
	frame := make([]byte, outboxWALHeaderSize+len(payload))
	binary.BigEndian.PutUint32(frame[0:4], outboxWALMagic)
	binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload)))
	binary.BigEndian.PutUint32(frame[8:12], crc32.ChecksumIEEE(payload))
	copy(frame[outboxWALHeaderSize:], payload)
	return frame
}

func outboxWALFileName(id uint64) string {
	return fmt.Sprintf("outbox-%020d.wal", id)
}

func parseOutboxWALFileName(name string) (uint64, error) {
	if !strings.HasPrefix(name, "outbox-") || !strings.HasSuffix(name, ".wal") {
		return 0, fmt.Errorf("invalid durable outbox wal file name %q", name)
	}
	value := strings.TrimSuffix(strings.TrimPrefix(name, "outbox-"), ".wal")
	id, err := strconv.ParseUint(value, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("parse durable outbox wal file name %q: %w", name, err)
	}
	return id, nil
}

func syncDirectory(dir string) error {
	handle, err := os.Open(dir)
	if err != nil {
		return err
	}
	syncErr := handle.Sync()
	closeErr := handle.Close()
	return errors.Join(syncErr, closeErr)
}

func minDuration(left, right time.Duration) time.Duration {
	if left <= 0 {
		return right
	}
	if right <= 0 || left < right {
		return left
	}
	return right
}
