// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

// Package chirp implements a client to communicate with the BIRD Internet
// Routing Daemon.
package chirp

import (
	"bufio"
	"fmt"
	"net"
	"strings"
	"time"
)

const (
	// Maximum amount of time we should wait when reading a response from BIRD.
	responseTimeout = 10 * time.Second
)

// New creates a BIRDClient.
func New(socket string) (*BIRDClient, error) {
	return newWithTimeout(socket, responseTimeout)
}

func newWithTimeout(socket string, timeout time.Duration) (_ *BIRDClient, err error) {
	conn, err := net.Dial("unix", socket)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to BIRD: %w", err)
	}
	defer func() {
		if err != nil {
			conn.Close()
		}
	}()

	b := &BIRDClient{
		socket:  socket,
		conn:    conn,
		scanner: bufio.NewScanner(conn),
		timeNow: time.Now,
		timeout: timeout,
	}
	// Read and discard the first line as that is the welcome message.
	if _, err := b.readResponse(); err != nil {
		return nil, err
	}
	return b, nil
}

// BIRDClient handles communication with the BIRD Internet Routing Daemon.
type BIRDClient struct {
	socket  string
	conn    net.Conn
	scanner *bufio.Scanner
	timeNow func() time.Time
	timeout time.Duration
}

// Close closes the underlying connection to BIRD.
func (b *BIRDClient) Close() error { return b.conn.Close() }

// DisableProtocol disables the provided protocol.
func (b *BIRDClient) DisableProtocol(protocol string) error {
	out, err := b.exec("disable %s", protocol)
	if err != nil {
		return err
	}
	if strings.Contains(out, fmt.Sprintf("%s: already disabled", protocol)) {
		return nil
	} else if strings.Contains(out, fmt.Sprintf("%s: disabled", protocol)) {
		return nil
	}
	return fmt.Errorf("failed to disable %s: %v", protocol, out)
}

// EnableProtocol enables the provided protocol.
func (b *BIRDClient) EnableProtocol(protocol string) error {
	out, err := b.exec("enable %s", protocol)
	if err != nil {
		return err
	}
	if strings.Contains(out, fmt.Sprintf("%s: already enabled", protocol)) {
		return nil
	} else if strings.Contains(out, fmt.Sprintf("%s: enabled", protocol)) {
		return nil
	}
	return fmt.Errorf("failed to enable %s: %v", protocol, out)
}

// BIRD CLI docs from https://bird.network.cz/?get_doc&v=20&f=prog-2.html#ss2.9

// Each session of the CLI consists of a sequence of request and replies,
// slightly resembling the FTP and SMTP protocols.
// Requests are commands encoded as a single line of text,
// replies are sequences of lines starting with a four-digit code
// followed by either a space (if it's the last line of the reply) or
// a minus sign (when the reply is going to continue with the next line),
// the rest of the line contains a textual message semantics of which depends on the numeric code.
// If a reply line has the same code as the previous one and it's a continuation line,
// the whole prefix can be replaced by a single white space character.
//
// Reply codes starting with 0 stand for ‘action successfully completed’ messages,
// 1 means ‘table entry’, 8 ‘runtime error’ and 9 ‘syntax error’.

func (b *BIRDClient) exec(cmd string, args ...any) (string, error) {
	if err := b.conn.SetWriteDeadline(b.timeNow().Add(b.timeout)); err != nil {
		return "", err
	}
	if _, err := fmt.Fprintf(b.conn, cmd, args...); err != nil {
		return "", err
	}
	if _, err := fmt.Fprintln(b.conn); err != nil {
		return "", err
	}
	return b.readResponse()
}

// hasResponseCode reports whether the provided byte slice is
// prefixed with a BIRD response code.
// Equivalent regex: `^\d{4}[ -]`.
func hasResponseCode(s []byte) bool {
	if len(s) < 5 {
		return false
	}
	for _, b := range s[:4] {
		if '0' <= b && b <= '9' {
			continue
		}
		return false
	}
	return s[4] == ' ' || s[4] == '-'
}

func (b *BIRDClient) readResponse() (string, error) {
	// Set the read timeout before we start reading anything.
	if err := b.conn.SetReadDeadline(b.timeNow().Add(b.timeout)); err != nil {
		return "", err
	}

	var resp strings.Builder
	var done bool
	for !done {
		if !b.scanner.Scan() {
			if err := b.scanner.Err(); err != nil {
				return "", err
			}

			return "", fmt.Errorf("reading response from bird failed (EOF): %q", resp.String())
		}
		out := b.scanner.Bytes()
		if _, err := resp.Write(out); err != nil {
			return "", err
		}
		if hasResponseCode(out) {
			done = out[4] == ' '
		}
		if !done {
			resp.WriteRune('\n')
		}
	}
	return resp.String(), nil
}
