=encoding utf8

=head1 NAME

std/string/quoted_printable - Quoted-printable encoders and decoders.

=head1 SYNOPSIS

  from std/string/quoted_printable import encode, decode;

  let raw := to_binary( "Hello, world!\r\n" );

  let text := encode(raw);
  let bytes := decode(text);

  let binary_text := encode(raw, binary: true);
  let short_lines := encode(raw, line_length: 40, newline: "\n");

=head1 IMPLEMENTATION SUPPORT

This module is supported by all implementations of ZuzuScript.

=head1 DESCRIPTION

This module provides quoted-printable encoding and decoding helpers for
RFC 2045-style byte transport. Encoding returns ASCII C<String> text.
Decoding returns a C<BinaryString>, because quoted-printable is a byte
transfer encoding rather than a Unicode text format.

The C<binary> option controls how input line break bytes are encoded.
In the default non-binary mode, CRLF, CR, and LF bytes are normalized to
the configured C<newline> string. In binary mode, CR and LF bytes are
encoded as C<=0D> and C<=0A>.

=head1 EXPORTS

=head2 Functions

=over

=item * C<encode(BinaryString bytes, ... PairList options)>

Parameters: C<bytes> is binary input data and C<options> controls
encoding. Returns: C<String>. Encodes C<bytes> as quoted-printable ASCII
text.

=item * C<decode(String text, ... PairList options)>

Parameters: C<text> is quoted-printable text and C<options> controls
strictness. Returns: C<BinaryString>. Decodes quoted-printable text into
bytes.

=back

=head1 OPTIONS

=over

=item * C<line_length>

Maximum encoded line length. Defaults to C<76> and must be at least
C<4>.

=item * C<newline>

Output newline for hard line breaks and encoded soft breaks. Defaults
to CRLF.

=item * C<binary>

When true, encode CR and LF bytes as C<=0D> and C<=0A>. Defaults to
false.

=item * C<strict>

When true, malformed quoted-printable escape sequences throw during
decoding. Non-strict decoding preserves malformed escape text
literally.

=back

=head1 COPYRIGHT AND LICENCE

B<< std/string/quoted_printable >> is copyright Toby Inkster.

It is free software; you may redistribute it and/or modify it under
the terms of either the Artistic License 1.0 or the GNU General Public
License version 2.

=cut


let encode := null;
let decode := null;

{
	from std/string import chr, index, ord, substr;
	from std/string/base64 import
		encode as _base64_encode,
		decode as _base64_decode;

	let _B64_ALPHABET := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
		_ "abcdefghijklmnopqrstuvwxyz0123456789+/";
	let _HEX := "0123456789ABCDEF";

	function _div_floor ( Number n, Number d ) {
		return floor( n / d );
	}

	function _mod ( Number n, Number d ) {
		return n - _div_floor( n, d ) * d;
	}

	function _bytes_to_binary ( Array bytes ) {
		let out := "";
		let i := 0;
		let n := bytes.length();

		while ( i < n ) {
			let b0 := bytes[i];
			let b1 := null;
			let b2 := null;
			if ( i + 1 < n ) {
				b1 := bytes[i + 1];
			}
			if ( i + 2 < n ) {
				b2 := bytes[i + 2];
			}

			let c0 := _div_floor( b0, 4 );
			let c1 := _mod( b0, 4 ) * 16;
			let c2 := 64;
			let c3 := 64;

			if ( not( b1 == null ) ) {
				c1 += _div_floor( b1, 16 );
				c2 := _mod( b1, 16 ) * 4;
				if ( not( b2 == null ) ) {
					c2 += _div_floor( b2, 64 );
					c3 := _mod( b2, 64 );
				}
			}

			out _= substr( _B64_ALPHABET, c0, 1 );
			out _= substr( _B64_ALPHABET, c1, 1 );
			out _= c2 == 64 ? "=" : substr( _B64_ALPHABET, c2, 1 );
			out _= c3 == 64 ? "=" : substr( _B64_ALPHABET, c3, 1 );
			i += 3;
		}

		return _base64_decode(out);
	}

	function _binary_to_bytes ( BinaryString raw ) {
		let b64 := _base64_encode(raw);
		let out := [];
		let i := 0;
		let n := length b64;

		while ( i < n ) {
			let c0 := index( _B64_ALPHABET, substr( b64, i, 1 ) );
			let c1 := index( _B64_ALPHABET, substr( b64, i + 1, 1 ) );
			let ch2 := substr( b64, i + 2, 1 );
			let ch3 := substr( b64, i + 3, 1 );
			let c2 := -1;
			let c3 := -1;
			if ( ch2 ne "=" ) {
				c2 := index( _B64_ALPHABET, ch2 );
			}
			if ( ch3 ne "=" ) {
				c3 := index( _B64_ALPHABET, ch3 );
			}

			out.push( c0 * 4 + _div_floor( c1, 16 ) );
			if ( c2 >= 0 ) {
				out.push( _mod( c1, 16 ) * 16 + _div_floor( c2, 4 ) );
			}
			if ( c3 >= 0 ) {
				out.push( _mod( c2, 4 ) * 64 + c3 );
			}

			i += 4;
		}

		return out;
	}

	function _parse_options ( PairList options ) {
		let line_length := 76;
		let newline := "\r\n";
		let binary := false;
		let strict := false;

		for ( let option in options.enumerate() ) {
			let key := option.key;
			let value := option.value;

			if ( key eq "line_length" ) {
				line_length := value;
			}
			else if ( key eq "newline" ) {
				newline := value;
			}
			else if ( key eq "binary" ) {
				binary := value;
			}
			else if ( key eq "strict" ) {
				strict := value;
			}
			else {
				die `quoted_printable option '${key}' is not supported`;
			}
		}

		if ( not( line_length instanceof Number ) ) {
			die "quoted_printable line_length option expects Number";
		}
		if ( line_length < 4 ) {
			die "quoted_printable line_length option must be at least 4";
		}
		if ( not( newline instanceof String ) ) {
			die "quoted_printable newline option expects String";
		}
		if ( not( binary instanceof Boolean ) ) {
			die "quoted_printable binary option expects Boolean";
		}
		if ( not( strict instanceof Boolean ) ) {
			die "quoted_printable strict option expects Boolean";
		}

		return {
			line_length: int(line_length),
			newline: newline,
			binary: binary,
			strict: strict,
		};
	}

	function _is_safe_literal ( Number b ) {
		return ( b >= 33 and b <= 60 ) or ( b >= 62 and b <= 126 );
	}

	function _byte_to_hex_token ( Number b ) {
		return "="
			_ substr( _HEX, _div_floor( b, 16 ), 1 )
			_ substr( _HEX, _mod( b, 16 ), 1 );
	}

	function _simple_token_length ( Number b, Boolean final_byte ) {
		if ( ( b == 9 or b == 32 ) and not final_byte ) {
			return 1;
		}
		if ( _is_safe_literal(b) ) {
			return 1;
		}
		return 3;
	}

	function _space_tab_needs_escape (
		Array bytes,
		Number i,
		Number column,
		Number line_length,
	) {
		let n := bytes.length();
		if ( i + 1 >= n ) {
			return true;
		}

		let j := i + 1;
		while ( j < n and ( bytes[j] == 9 or bytes[j] == 32 ) ) {
			j++;
		}
		if ( j >= n ) {
			return true;
		}

		let next_is_final := i + 2 >= n;
		let next_len := _simple_token_length( bytes[i + 1], next_is_final );
		let max := next_is_final ? line_length : line_length - 1;
		return column + 1 + next_len > max;
	}

	function _token_for_byte (
		Array bytes,
		Number i,
		Number column,
		Number line_length,
	) {
		let b := bytes[i];

		if ( b == 9 or b == 32 ) {
			if ( _space_tab_needs_escape( bytes, i, column, line_length ) ) {
				return _byte_to_hex_token(b);
			}
			return chr(b);
		}

		if ( _is_safe_literal(b) ) {
			return chr(b);
		}

		return _byte_to_hex_token(b);
	}

	function _emit_token (
		String out,
		Number column,
		String token,
		Boolean more_after,
		Number line_length,
		String newline,
	) {
		let max := more_after ? line_length - 1 : line_length;
		let updated_out := out;
		let updated_column := column;

		if ( updated_column > 0 and updated_column + ( length token ) > max ) {
			updated_out _= "=" _ newline;
			updated_column := 0;
		}

		updated_out _= token;
		updated_column += length token;

		return [ updated_out, updated_column ];
	}

	function _encode_segment (
		Array bytes,
		Number line_length,
		String newline,
	) {
		let out := "";
		let column := 0;
		let i := 0;
		let n := bytes.length();

		while ( i < n ) {
			let token := _token_for_byte( bytes, i, column, line_length );
			let emitted := _emit_token(
				out,
				column,
				token,
				i + 1 < n,
				line_length,
				newline,
			);
			out := emitted[0];
			column := emitted[1];
			i++;
		}

		return out;
	}

	function _encode_text_bytes (
		Array bytes,
		Number line_length,
		String newline,
	) {
		let out := "";
		let line := [];
		let i := 0;
		let n := bytes.length();

		while ( i < n ) {
			let b := bytes[i];
			if ( b == 13 or b == 10 ) {
				out _= _encode_segment( line, line_length, newline );
				out _= newline;
				line := [];

				if ( b == 13 and i + 1 < n and bytes[i + 1] == 10 ) {
					i++;
				}
			}
			else {
				line.push(b);
			}

			i++;
		}

		out _= _encode_segment( line, line_length, newline );
		return out;
	}

	function _hex_value ( String ch ) {
		let cp := ord(ch);
		if ( cp >= 48 and cp <= 57 ) {
			return cp - 48;
		}
		if ( cp >= 65 and cp <= 70 ) {
			return cp - 55;
		}
		if ( cp >= 97 and cp <= 102 ) {
			return cp - 87;
		}
		return -1;
	}

	function _decode_text_bytes ( String text, Boolean strict ) {
		let out := [];
		let i := 0;
		let n := length text;

		while ( i < n ) {
			let ch := substr( text, i, 1 );
			let cp := ord( text, i );

			if ( cp > 127 ) {
				die "quoted_printable.decode rejects non-ASCII input";
			}

			if ( ch eq "=" ) {
				if ( i + 1 >= n ) {
					die "malformed quoted-printable escape" if strict;
					out.push(61);
					i++;
					next;
				}

				let ch1 := substr( text, i + 1, 1 );
				if ( ch1 eq "\r" ) {
					if ( i + 2 < n and substr( text, i + 2, 1 ) eq "\n" ) {
						i += 3;
					}
					else {
						i += 2;
					}
					next;
				}
				if ( ch1 eq "\n" ) {
					i += 2;
					next;
				}

				if ( i + 2 < n ) {
					let hi := _hex_value(ch1);
					let lo := _hex_value( substr( text, i + 2, 1 ) );
					if ( hi >= 0 and lo >= 0 ) {
						out.push( hi * 16 + lo );
						i += 3;
						next;
					}
				}

				die "malformed quoted-printable escape" if strict;
				out.push(61);
				i++;
				next;
			}

			out.push(cp);
			i++;
		}

		return out;
	}

	encode := function ( BinaryString bytes, ... PairList options ) {
		let opts := _parse_options(options);
		let raw := _binary_to_bytes(bytes);

		if ( opts{binary} ) {
			return _encode_segment(
				raw,
				opts{line_length},
				opts{newline},
			);
		}

		return _encode_text_bytes(
			raw,
			opts{line_length},
			opts{newline},
		);
	};

	decode := function ( String text, ... PairList options ) {
		let opts := _parse_options(options);
		return _bytes_to_binary( _decode_text_bytes( text, opts{strict} ) );
	};
}
