package com.lemenzo.contacts;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ByteArrayInputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/** Local-only VCF/CSV codec used by Lemenzo Contacts import/export. */
public final class ContactFileCodec {
    private static final Charset BYTE_PRESERVING = StandardCharsets.ISO_8859_1;

    private ContactFileCodec() {}

    public static void writeCsv(OutputStream out, List<ContactData> contacts) throws IOException {
        writeCsv(out, contacts, true);
    }

    public static void writeCsv(OutputStream out, List<ContactData> contacts, boolean includeHeader) throws IOException {
        if (out == null) throw new IOException("Output stream is null");
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
        if (includeHeader) writer.write("First Name,Middle Name,Last Name,Display Name,Phone,Email,Company,Job Title,Address,Birthday,Notes\n");
        for (ContactData d : contacts) {
            String[] values = {
                    safe(d.firstName), safe(d.middleName), safe(d.lastName), safe(d.displayName),
                    String.join(" | ", d.phones), String.join(" | ", d.emails), safe(d.company),
                    safe(d.jobTitle), safe(d.address), safe(d.birthday), safe(d.notes)
            };
            for (int i = 0; i < values.length; i++) {
                if (i > 0) writer.write(',');
                writer.write(csv(values[i]));
            }
            writer.write('\n');
        }
        writer.flush();
    }

    private static String csv(String value) {
        return "\"" + safe(value).replace("\"", "\"\"") + "\"";
    }

    /**
     * Reads comma, semicolon or tab-delimited CSV, including quoted multiline fields and Excel's
     * optional "sep=;" first line. UTF-8 BOM is ignored.
     */
    public static List<String[]> readCsv(InputStream in) throws IOException {
        if (in == null) throw new IOException("Input stream is null");
        String text = new String(in.readAllBytes(), StandardCharsets.UTF_8);
        if (text.startsWith("\uFEFF")) text = text.substring(1);

        char delimiter = ',';
        int firstBreak = firstLineBreak(text);
        String firstLine = firstBreak >= 0 ? text.substring(0, firstBreak) : text;
        String trimmedFirst = firstLine.trim();
        if (trimmedFirst.length() == 5 && trimmedFirst.regionMatches(true, 0, "sep=", 0, 4)) {
            char declared = trimmedFirst.charAt(4);
            if (declared == ',' || declared == ';' || declared == '\t') delimiter = declared;
            text = firstBreak >= 0 ? skipLineBreak(text, firstBreak) : "";
        } else {
            delimiter = detectDelimiter(firstLine);
        }

        ArrayList<String[]> rows = new ArrayList<>();
        ArrayList<String> row = new ArrayList<>();
        StringBuilder field = new StringBuilder();
        boolean quoted = false;

        for (int i = 0; i < text.length(); i++) {
            char ch = text.charAt(i);
            if (quoted) {
                if (ch == '"' && i + 1 < text.length() && text.charAt(i + 1) == '"') {
                    field.append('"');
                    i++;
                } else if (ch == '"') {
                    quoted = false;
                } else {
                    field.append(ch);
                }
            } else if (ch == '"') {
                quoted = true;
            } else if (ch == delimiter) {
                row.add(field.toString());
                field.setLength(0);
            } else if (ch == '\n' || ch == '\r') {
                if (ch == '\r' && i + 1 < text.length() && text.charAt(i + 1) == '\n') i++;
                row.add(field.toString());
                field.setLength(0);
                if (!isCompletelyEmpty(row)) rows.add(row.toArray(new String[0]));
                row = new ArrayList<>();
            } else {
                field.append(ch);
            }
        }
        if (quoted) throw new IOException("Malformed CSV: unclosed quoted field");
        if (field.length() > 0 || !row.isEmpty()) {
            row.add(field.toString());
            if (!isCompletelyEmpty(row)) rows.add(row.toArray(new String[0]));
        }
        return rows;
    }


    public interface CsvRowConsumer { void onRow(String[] row, int rowIndex) throws IOException; }

    /** Streaming CSV parser with quoted multiline fields; does not retain the whole file in RAM. */
    public static int readCsvStreaming(InputStream in, CsvRowConsumer consumer) throws IOException {
        if(in==null)throw new IOException("Input stream is null");
        if(consumer==null)throw new IOException("Consumer is null");
        java.io.PushbackReader r=new java.io.PushbackReader(new java.io.BufferedReader(
                new java.io.InputStreamReader(in,StandardCharsets.UTF_8),64*1024),4);
        StringBuilder first=new StringBuilder();
        int ch;
        while((ch=r.read())!=-1&&ch!='\n'&&ch!='\r')first.append((char)ch);
        if(ch=='\r'){int n=r.read();if(n!='\n'&&n!=-1)r.unread(n);}
        String firstLine=first.toString();
        if(firstLine.startsWith("\uFEFF"))firstLine=firstLine.substring(1);
        char delimiter;
        boolean sep=firstLine.trim().length()==5&&firstLine.trim().regionMatches(true,0,"sep=",0,4);
        if(sep){char d=firstLine.trim().charAt(4);delimiter=(d==';'||d=='\t')?d:',';}
        else{delimiter=detectDelimiter(firstLine);for(int i=firstLine.length()-1;i>=0;i--)r.unread(firstLine.charAt(i));}

        ArrayList<String> row=new ArrayList<>();StringBuilder field=new StringBuilder();
        boolean quoted=false;int rowIndex=0;
        while((ch=r.read())!=-1){
            char c=(char)ch;
            if(quoted){
                if(c=='"'){int n=r.read();if(n=='"')field.append('"');else{quoted=false;if(n!=-1)r.unread(n);}}
                else field.append(c);
            }else if(c=='"'){quoted=true;}
            else if(c==delimiter){row.add(field.toString());field.setLength(0);}
            else if(c=='\n'||c=='\r'){
                if(c=='\r'){int n=r.read();if(n!='\n'&&n!=-1)r.unread(n);}
                row.add(field.toString());field.setLength(0);
                if(!isCompletelyEmpty(row)){consumer.onRow(row.toArray(new String[0]),rowIndex++);}
                row=new ArrayList<>();
            }else field.append(c);
        }
        if(quoted)throw new IOException("Malformed CSV: unclosed quoted field");
        if(field.length()>0||!row.isEmpty()){row.add(field.toString());if(!isCompletelyEmpty(row))consumer.onRow(row.toArray(new String[0]),rowIndex++);}
        return rowIndex;
    }

    private static int firstLineBreak(String text) {
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            if (c == '\r' || c == '\n') return i;
        }
        return -1;
    }

    private static String skipLineBreak(String text, int at) {
        int next = at + 1;
        if (text.charAt(at) == '\r' && next < text.length() && text.charAt(next) == '\n') next++;
        return text.substring(next);
    }

    private static char detectDelimiter(String firstLine) {
        char[] candidates = {',', ';', '\t'};
        int bestCount = -1;
        char best = ',';
        for (char candidate : candidates) {
            boolean quoted = false;
            int count = 0;
            for (int i = 0; i < firstLine.length(); i++) {
                char c = firstLine.charAt(i);
                if (c == '"') {
                    if (quoted && i + 1 < firstLine.length() && firstLine.charAt(i + 1) == '"') {
                        i++;
                    } else {
                        quoted = !quoted;
                    }
                } else if (!quoted && c == candidate) {
                    count++;
                }
            }
            if (count > bestCount) {
                bestCount = count;
                best = candidate;
            }
        }
        return best;
    }

    private static boolean isCompletelyEmpty(List<String> row) {
        for (String value : row) if (!value.trim().isEmpty()) return false;
        return true;
    }

    public static void writeVcf(OutputStream out, List<ContactData> contacts) throws IOException {
        if (out == null) throw new IOException("Output stream is null");
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
        for (ContactData d : contacts) {
            writer.write("BEGIN:VCARD\r\n");
            writer.write("VERSION:3.0\r\n");
            writer.write("N:" + esc(d.lastName) + ";" + esc(d.firstName) + ";" + esc(d.middleName) + ";;\r\n");
            writer.write("FN:" + esc(exportDisplayName(d)) + "\r\n");
            for (int i = 0; i < d.phones.size(); i++) {
                int type = i < d.phoneTypes.size() ? d.phoneTypes.get(i) : 2;
                String label = type == 1 ? "HOME" : type == 3 ? "WORK" : type == 7 ? "OTHER" : "CELL";
                writer.write("TEL;TYPE=" + label + ":" + esc(d.phones.get(i)) + "\r\n");
            }
            for (int i = 0; i < d.emails.size(); i++) {
                int type = i < d.emailTypes.size() ? d.emailTypes.get(i) : 1;
                String label = type == 2 ? "WORK" : type == 3 ? "OTHER" : "HOME";
                writer.write("EMAIL;TYPE=" + label + ":" + esc(d.emails.get(i)) + "\r\n");
            }
            if (!safe(d.company).isEmpty()) writer.write("ORG:" + esc(d.company) + "\r\n");
            if (!safe(d.jobTitle).isEmpty()) writer.write("TITLE:" + esc(d.jobTitle) + "\r\n");
            if (!safe(d.address).isEmpty()) writer.write("ADR;TYPE=HOME:;;" + esc(d.address) + ";;;;\r\n");
            if (!safe(d.birthday).isEmpty()) writer.write("BDAY:" + esc(d.birthday) + "\r\n");
            if (!safe(d.notes).isEmpty()) writer.write("NOTE:" + esc(d.notes) + "\r\n");
            if (d.photoBytes != null && d.photoBytes.length > 0) {
                String type = photoType(d.photoBytes);
                writer.write("PHOTO;ENCODING=b" + (type.isEmpty() ? "" : ";TYPE=" + type)
                        + ":" + Base64.getEncoder().encodeToString(d.photoBytes) + "\r\n");
            }
            writer.write("END:VCARD\r\n");
        }
        writer.flush();
    }

    public interface ContactBatchConsumer {
        void onBatch(List<ContactData> contacts) throws IOException;
    }

    /**
     * Streaming vCard reader for large address books. Only one vCard and a bounded output batch
     * are retained at a time, preventing multi-thousand-contact imports from ballooning the heap.
     */
    public static int readVcfBatched(InputStream in, int batchSize, ContactBatchConsumer consumer)
            throws IOException {
        if (in == null) throw new IOException("Input stream is null");
        if (consumer == null) throw new IOException("Consumer is null");
        int limit = Math.max(1, batchSize);
        int total = 0;
        ArrayList<ContactData> batch = new ArrayList<>(limit);
        StringBuilder card = null;

        try (BufferedReader reader = new BufferedReader(new InputStreamReader(in, BYTE_PRESERVING), 64 * 1024)) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.equalsIgnoreCase("BEGIN:VCARD")) {
                    card = new StringBuilder(1024);
                    card.append("BEGIN:VCARD\n");
                    continue;
                }
                if (card == null) continue;
                card.append(line).append('\n');
                if (!line.equalsIgnoreCase("END:VCARD")) continue;

                byte[] bytes = card.toString().getBytes(BYTE_PRESERVING);
                List<ContactData> parsed = readVcf(new ByteArrayInputStream(bytes));
                if (!parsed.isEmpty()) {
                    ContactData contact = parsed.get(0);
                    if (hasImportableData(contact)) {
                        batch.add(contact);
                        total++;
                        if (batch.size() >= limit) {
                            consumer.onBatch(batch);
                            batch = new ArrayList<>(limit);
                        }
                    }
                }
                card = null;
            }
        }
        if (!batch.isEmpty()) consumer.onBatch(batch);
        return total;
    }

    /** Supports common vCard 2.1, 3.0 and 4.0 text forms used by Android/iOS/contact tools. */
    public static List<ContactData> readVcf(InputStream in) throws IOException {
        if (in == null) throw new IOException("Input stream is null");

        // ISO-8859-1 is intentionally used as a byte-preserving transport. Individual property
        // values are decoded with their CHARSET parameter (or UTF-8 with a Latin-1 fallback).
        String raw = new String(in.readAllBytes(), BYTE_PRESERVING)
                .replace("\r\n", "\n")
                .replace('\r', '\n');
        List<String> lines = unfoldVcardLines(raw.split("\n", -1));

        ArrayList<ContactData> contacts = new ArrayList<>();
        ContactData current = null;
        for (String line : lines) {
            if (line.equalsIgnoreCase("BEGIN:VCARD")) {
                current = new ContactData();
                continue;
            }
            if (line.equalsIgnoreCase("END:VCARD")) {
                if (current != null) {
                    normalizeContact(current);
                    if (hasImportableData(current)) contacts.add(current);
                }
                current = null;
                continue;
            }
            if (current == null || line.isEmpty()) continue;

            int colon = line.indexOf(':');
            if (colon <= 0) continue;
            String left = line.substring(0, colon);
            String rawValue = line.substring(colon + 1);
            ParsedProperty property = parseProperty(left);
            String name = property.name;

            if (name.equals("FN")) {
                current.displayName = decodeValue(rawValue, property);
            } else if (name.equals("N")) {
                List<String> parts = splitStructured(rawValue, ';');
                if (parts.size() > 0) current.lastName = decodeValue(parts.get(0), property);
                if (parts.size() > 1) current.firstName = decodeValue(parts.get(1), property);
                if (parts.size() > 2) current.middleName = decodeValue(parts.get(2), property);
            } else if (name.equals("TEL")) {
                String value = decodeValue(rawValue, property).trim();
                if (value.regionMatches(true, 0, "tel:", 0, 4)) value = value.substring(4);
                if (!value.isEmpty()) {
                    current.phones.add(value);
                    current.phoneTypes.add(typeFrom(property, true));
                }
            } else if (name.equals("EMAIL")) {
                String value = decodeValue(rawValue, property).trim();
                if (value.regionMatches(true, 0, "mailto:", 0, 7)) value = value.substring(7);
                if (!value.isEmpty()) {
                    current.emails.add(value);
                    current.emailTypes.add(typeFrom(property, false));
                }
            } else if (name.equals("ORG")) {
                current.company = joinDecodedStructured(rawValue, property, " / ");
            } else if (name.equals("TITLE")) {
                current.jobTitle = decodeValue(rawValue, property);
            } else if (name.equals("ADR")) {
                current.address = formattedAddress(rawValue, property);
            } else if (name.equals("BDAY")) {
                current.birthday = decodeValue(rawValue, property);
            } else if (name.equals("NOTE")) {
                current.notes = decodeValue(rawValue, property);
            } else if (name.equals("PHOTO")) {
                byte[] photo = decodePhoto(rawValue, property);
                if (photo != null && photo.length > 0) current.photoBytes = photo;
            }
        }

        // Tolerate a truncated final card only if it still contains useful data.
        if (current != null) {
            normalizeContact(current);
            if (hasImportableData(current)) contacts.add(current);
        }
        return contacts;
    }

    public static boolean hasImportableData(ContactData d) {
        return d != null && (!safe(d.displayName).trim().isEmpty()
                || !safe(d.firstName).trim().isEmpty()
                || !safe(d.middleName).trim().isEmpty()
                || !safe(d.lastName).trim().isEmpty()
                || !d.phones.isEmpty()
                || !d.emails.isEmpty()
                || !safe(d.company).trim().isEmpty()
                || !safe(d.jobTitle).trim().isEmpty()
                || !safe(d.address).trim().isEmpty()
                || !safe(d.birthday).trim().isEmpty()
                || !safe(d.notes).trim().isEmpty()
                || (d.photoBytes != null && d.photoBytes.length > 0));
    }

    private static List<String> unfoldVcardLines(String[] physicalLines) {
        ArrayList<String> out = new ArrayList<>();
        for (String physical : physicalLines) {
            if (!out.isEmpty()) {
                int last = out.size() - 1;
                String previous = out.get(last);
                int colon = previous.indexOf(':');
                String descriptor = colon > 0 ? previous.substring(0, colon).toUpperCase(Locale.ROOT) : "";
                if (previous.endsWith("=") && descriptor.contains("QUOTED-PRINTABLE")) {
                    String continuation = (physical.startsWith(" ") || physical.startsWith("\t"))
                            ? physical.substring(1) : physical;
                    out.set(last, previous.substring(0, previous.length() - 1) + continuation);
                    continue;
                }
                if ((descriptor.startsWith("PHOTO;") || descriptor.equals("PHOTO"))
                        && (descriptor.contains("BASE64") || descriptor.contains("ENCODING=B"))
                        && !physical.isEmpty() && physical.indexOf(':') < 0) {
                    out.set(last, previous + physical.trim());
                    continue;
                }
            }
            if (!out.isEmpty() && (physical.startsWith(" ") || physical.startsWith("\t"))) {
                int last = out.size() - 1;
                out.set(last, out.get(last) + physical.substring(1));
                continue;
            }
            out.add(physical);
        }
        return out;
    }

    private static ParsedProperty parseProperty(String left) {
        String[] tokens = left.split(";");
        String rawName = tokens[0].trim();
        int dot = rawName.lastIndexOf('.');
        if (dot >= 0 && dot + 1 < rawName.length()) rawName = rawName.substring(dot + 1);
        ParsedProperty p = new ParsedProperty(rawName.toUpperCase(Locale.ROOT));
        for (int i = 1; i < tokens.length; i++) {
            String token = tokens[i].trim();
            int eq = token.indexOf('=');
            if (eq > 0) {
                String key = token.substring(0, eq).trim().toUpperCase(Locale.ROOT);
                String value = unquote(token.substring(eq + 1).trim());
                p.params.put(key, value);
            } else if (!token.isEmpty()) {
                p.bareParams.add(token.toUpperCase(Locale.ROOT));
            }
        }
        return p;
    }

    private static String unquote(String value) {
        if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
            return value.substring(1, value.length() - 1);
        }
        return value;
    }

    private static String decodeValue(String rawValue, ParsedProperty property) {
        byte[] bytes = property.isQuotedPrintable()
                ? decodeQuotedPrintable(rawValue.getBytes(BYTE_PRESERVING))
                : rawValue.getBytes(BYTE_PRESERVING);
        String decoded = decodeBytes(bytes, property.params.get("CHARSET"));
        return unesc(decoded);
    }

    private static String decodeBytes(byte[] bytes, String charsetName) {
        if (charsetName != null && !charsetName.trim().isEmpty()) {
            try {
                return new String(bytes, Charset.forName(charsetName.trim()));
            } catch (Exception ignored) {
                // Fall through to the robust default below.
            }
        }
        try {
            return StandardCharsets.UTF_8.newDecoder()
                    .onMalformedInput(CodingErrorAction.REPORT)
                    .onUnmappableCharacter(CodingErrorAction.REPORT)
                    .decode(ByteBuffer.wrap(bytes)).toString();
        } catch (CharacterCodingException ignored) {
            return new String(bytes, BYTE_PRESERVING);
        }
    }

    private static byte[] decodeQuotedPrintable(byte[] input) {
        ByteArrayOutputStream out = new ByteArrayOutputStream(input.length);
        for (int i = 0; i < input.length; i++) {
            int b = input[i] & 0xff;
            if (b == '=' && i + 2 < input.length) {
                int hi = hex(input[i + 1]);
                int lo = hex(input[i + 2]);
                if (hi >= 0 && lo >= 0) {
                    out.write((hi << 4) | lo);
                    i += 2;
                    continue;
                }
            }
            out.write(b);
        }
        return out.toByteArray();
    }

    private static int hex(byte value) {
        char c = (char) (value & 0xff);
        if (c >= '0' && c <= '9') return c - '0';
        if (c >= 'A' && c <= 'F') return c - 'A' + 10;
        if (c >= 'a' && c <= 'f') return c - 'a' + 10;
        return -1;
    }

    private static List<String> splitStructured(String value, char delimiter) {
        ArrayList<String> parts = new ArrayList<>();
        StringBuilder part = new StringBuilder();
        boolean escaped = false;
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            if (escaped) {
                part.append('\\').append(c);
                escaped = false;
            } else if (c == '\\') {
                escaped = true;
            } else if (c == delimiter) {
                parts.add(part.toString());
                part.setLength(0);
            } else {
                part.append(c);
            }
        }
        if (escaped) part.append('\\');
        parts.add(part.toString());
        return parts;
    }

    private static String joinDecodedStructured(String rawValue, ParsedProperty property, String separator) {
        StringBuilder out = new StringBuilder();
        for (String rawPart : splitStructured(rawValue, ';')) {
            String part = decodeValue(rawPart, property).trim();
            if (part.isEmpty()) continue;
            if (out.length() > 0) out.append(separator);
            out.append(part);
        }
        return out.toString();
    }

    private static String formattedAddress(String rawValue, ParsedProperty property) {
        List<String> rawParts = splitStructured(rawValue, ';');
        ArrayList<String> parts = new ArrayList<>();
        // vCard ADR: PO box;extended;street;locality;region;postal code;country.
        for (int i = 0; i < rawParts.size(); i++) {
            String value = decodeValue(rawParts.get(i), property).trim();
            if (!value.isEmpty()) parts.add(value);
        }
        return String.join("\n", parts);
    }

    private static int typeFrom(ParsedProperty property, boolean phone) {
        String types = property.typeTokens();
        if (phone) {
            if (types.contains("WORK")) return 3;
            if (types.contains("HOME")) return 1;
            if (types.contains("OTHER")) return 7;
            return 2; // Mobile/default.
        }
        if (types.contains("WORK")) return 2;
        if (types.contains("OTHER")) return 3;
        return 1; // Home/default.
    }

    private static byte[] decodePhoto(String rawValue, ParsedProperty property) {
        try {
            String value = rawValue.trim();
            if (value.regionMatches(true, 0, "data:", 0, 5)) {
                int comma = value.indexOf(',');
                if (comma > 0 && value.substring(0, comma).toLowerCase(Locale.ROOT).contains(";base64")) {
                    value = value.substring(comma + 1);
                } else {
                    return null;
                }
            }
            String encoding = safe(property.params.get("ENCODING")).toUpperCase(Locale.ROOT);
            if (encoding.equals("B") || encoding.equals("BASE64") || property.typeTokens().contains("BASE64")
                    || rawValue.regionMatches(true, 0, "data:", 0, 5)) {
                return Base64.getMimeDecoder().decode(value.replaceAll("\\s+", ""));
            }
        } catch (IllegalArgumentException ignored) {
            // Invalid image data: skip the photo without dropping the contact.
        }
        return null;
    }

    private static String photoType(byte[] bytes) {
        if (bytes == null || bytes.length < 4) return "";
        if ((bytes[0] & 0xff) == 0xff && (bytes[1] & 0xff) == 0xd8) return "JPEG";
        if ((bytes[0] & 0xff) == 0x89 && bytes[1] == 'P' && bytes[2] == 'N' && bytes[3] == 'G') return "PNG";
        return "";
    }

    private static void normalizeContact(ContactData d) {
        d.firstName = safe(d.firstName).trim();
        d.middleName = safe(d.middleName).trim();
        d.lastName = safe(d.lastName).trim();
        d.displayName = safe(d.displayName).trim();
        d.company = safe(d.company).trim();
        d.jobTitle = safe(d.jobTitle).trim();
        d.address = safe(d.address).trim();
        d.birthday = safe(d.birthday).trim();
        d.notes = safe(d.notes).trim();
        if (d.displayName.isEmpty()) {
            d.displayName = (d.firstName + " " + d.middleName + " " + d.lastName)
                    .trim().replaceAll("\\s+", " ");
        }
        if (d.displayName.isEmpty() && !d.phones.isEmpty()) d.displayName = d.phones.get(0);
        if (d.displayName.isEmpty() && !d.emails.isEmpty()) d.displayName = d.emails.get(0);
    }

    private static String exportDisplayName(ContactData d) {
        String value = safe(d.displayName).trim();
        if (!value.isEmpty()) return value;
        value = (safe(d.firstName) + " " + safe(d.middleName) + " " + safe(d.lastName))
                .trim().replaceAll("\\s+", " ");
        if (!value.isEmpty()) return value;
        if (!d.phones.isEmpty()) return d.phones.get(0);
        if (!d.emails.isEmpty()) return d.emails.get(0);
        return "Contact";
    }

    private static String esc(String value) {
        return safe(value)
                .replace("\\", "\\\\")
                .replace("\r\n", "\n")
                .replace("\r", "\n")
                .replace("\n", "\\n")
                .replace(";", "\\;")
                .replace(",", "\\,");
    }

    private static String unesc(String value) {
        StringBuilder out = new StringBuilder(value.length());
        for (int i = 0; i < value.length(); i++) {
            char c = value.charAt(i);
            if (c == '\\' && i + 1 < value.length()) {
                char next = value.charAt(++i);
                if (next == 'n' || next == 'N') out.append('\n');
                else out.append(next);
            } else {
                out.append(c);
            }
        }
        return out.toString();
    }

    private static String safe(String value) {
        return value == null ? "" : value;
    }

    private static final class ParsedProperty {
        final String name;
        final Map<String, String> params = new LinkedHashMap<>();
        final List<String> bareParams = new ArrayList<>();

        ParsedProperty(String name) {
            this.name = name;
        }

        boolean isQuotedPrintable() {
            String encoding = safe(params.get("ENCODING")).toUpperCase(Locale.ROOT);
            return encoding.equals("QUOTED-PRINTABLE") || bareParams.contains("QUOTED-PRINTABLE");
        }

        String typeTokens() {
            StringBuilder out = new StringBuilder();
            String type = params.get("TYPE");
            if (type != null) out.append(type.toUpperCase(Locale.ROOT));
            for (String bare : bareParams) out.append(',').append(bare);
            return out.toString();
        }
    }
}
