// Copyright 2019 The age Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package armor provides a strict, streaming implementation of the ASCII
// armoring format for age files.
//
// It's PEM with type "AGE ENCRYPTED FILE", 64 character columns, no headers,
// and strict base64 decoding.
package armor

import (
	"bufio"
	"bytes"
	"encoding/base64"
	"errors"
	"fmt"
	"io"

	"filippo.io/age/internal/format"
)

const (
	Header = "-----BEGIN AGE ENCRYPTED FILE-----"
	Footer = "-----END AGE ENCRYPTED FILE-----"
)

type armoredWriter struct {
	started, closed bool
	encoder         *format.WrappedBase64Encoder
	dst             io.Writer
}

func (a *armoredWriter) writeHeader() error {
	if a.started {
		return nil
	}
	if _, err := io.WriteString(a.dst, Header+"\n"); err != nil {
		return err
	}
	a.started = true
	return nil
}

func (a *armoredWriter) Write(p []byte) (int, error) {
	if a.closed {
		return 0, errors.New("ArmoredWriter already closed")
	}
	if err := a.writeHeader(); err != nil {
		return 0, err
	}
	return a.encoder.Write(p)
}

func (a *armoredWriter) Close() error {
	if a.closed {
		return errors.New("ArmoredWriter already closed")
	}
	a.closed = true
	if err := a.writeHeader(); err != nil {
		return err
	}
	if err := a.encoder.Close(); err != nil {
		return err
	}
	footer := Footer + "\n"
	if !a.encoder.LastLineIsEmpty() {
		footer = "\n" + footer
	}
	_, err := io.WriteString(a.dst, footer)
	return err
}

func NewWriter(dst io.Writer) io.WriteCloser {
	// TODO: write a test with aligned and misaligned sizes, and 8 and 10 steps.
	return &armoredWriter{
		dst:     dst,
		encoder: format.NewWrappedBase64Encoder(base64.StdEncoding, dst),
	}
}

type armoredReader struct {
	r       *bufio.Reader
	started bool
	unread  []byte // backed by buf
	buf     [format.BytesPerLine]byte
	err     error
}

func NewReader(r io.Reader) io.Reader {
	return &armoredReader{r: bufio.NewReader(r)}
}

func (r *armoredReader) Read(p []byte) (int, error) {
	if len(r.unread) > 0 {
		n := copy(p, r.unread)
		r.unread = r.unread[n:]
		return n, nil
	}
	if r.err != nil {
		return 0, r.err
	}

	getLine := func() ([]byte, error) {
		line, err := r.r.ReadBytes('\n')
		if err == io.EOF && len(line) == 0 {
			return nil, io.ErrUnexpectedEOF
		} else if err != nil && err != io.EOF {
			return nil, err
		}
		line = bytes.TrimSuffix(line, []byte("\n"))
		line = bytes.TrimSuffix(line, []byte("\r"))
		return line, nil
	}

	const maxWhitespace = 1024
	drainTrailing := func() error {
		buf, err := io.ReadAll(io.LimitReader(r.r, maxWhitespace))
		if err != nil {
			return err
		}
		if len(bytes.TrimSpace(buf)) != 0 {
			return errors.New("trailing data after armored file")
		}
		if len(buf) == maxWhitespace {
			return errors.New("too much trailing whitespace")
		}
		return io.EOF
	}

	var removedWhitespace int
	for !r.started {
		line, err := getLine()
		if err != nil {
			return 0, r.setErr(err)
		}
		// Ignore leading whitespace.
		if len(bytes.TrimSpace(line)) == 0 {
			removedWhitespace += len(line) + 1
			if removedWhitespace > maxWhitespace {
				return 0, r.setErr(errors.New("too much leading whitespace"))
			}
			continue
		}
		if string(line) != Header {
			return 0, r.setErr(fmt.Errorf("invalid first line: %q", line))
		}
		r.started = true
	}
	line, err := getLine()
	if err != nil {
		return 0, r.setErr(err)
	}
	if string(line) == Footer {
		return 0, r.setErr(drainTrailing())
	}
	if len(line) == 0 {
		return 0, r.setErr(errors.New("empty line in armored data"))
	}
	if len(line) > format.ColumnsPerLine {
		return 0, r.setErr(errors.New("column limit exceeded"))
	}
	// Reject newline characters ignored by base64.Decode.
	if bytes.ContainsAny(line, "\n\r") {
		return 0, r.setErr(errors.New("unexpected newline character"))
	}
	r.unread = r.buf[:]
	n, err := base64.StdEncoding.Strict().Decode(r.unread, line)
	if err != nil {
		return 0, r.setErr(err)
	}
	r.unread = r.unread[:n]

	if n < format.BytesPerLine {
		line, err := getLine()
		if err != nil {
			return 0, r.setErr(err)
		}
		if string(line) != Footer {
			return 0, r.setErr(fmt.Errorf("invalid closing line: %q", line))
		}
		r.setErr(drainTrailing())
	}

	nn := copy(p, r.unread)
	r.unread = r.unread[nn:]
	return nn, nil
}

type Error struct {
	err error
}

func (e *Error) Error() string {
	return "invalid armor: " + e.err.Error()
}

func (e *Error) Unwrap() error {
	return e.err
}

func (r *armoredReader) setErr(err error) error {
	if err != io.EOF {
		err = &Error{err}
	}
	r.err = err
	return err
}
