import Foundation

/// **RFC 4180, both directions** — the delimited-text plumbing under the CSV converter, and nothing
/// about boards at all (15-import-export.md ▸ The formats ▸ CSV).
///
/// Its own type rather than two private helpers because the two halves have to agree exactly, and the
/// agreement is the only thing worth testing here: a field this writer quotes is a field this reader
/// unquotes, at every one of the four characters that force the issue.
///
/// ### The writer's rules
///
/// - A field is quoted when it contains a comma, a double quote, CR or LF — and left bare otherwise, so
///   an ordinary export stays readable in a text editor.
/// - An embedded double quote is doubled (`"` → `""`), which is RFC 4180's only escape.
/// - **Records end with CRLF**, which the RFC requires and which is the one place this app's own
///   LF-everywhere rule (01-storage-format.md § Encoding and line endings) deliberately does not reach:
///   that rule is about the files a *board* is made of, and a CSV is an interchange document written for
///   other people's tools. Every reader in the world takes both; the standard names one.
/// - The text is UTF-8 with **no BOM**. Excel on Windows prefers one and every other consumer is worse
///   off for it; the app's own encoding rule breaks the tie.
///
/// ### The reader's rules
///
/// Deliberately more forgiving than the writer, because it is reading somebody else's file: LF, CRLF and
/// bare CR all end a record, a BOM is dropped, a quote appearing mid-field is literal text rather than a
/// syntax error, and a final record with no terminator is still a record. A trailing terminator does not
/// produce a phantom empty record.
public enum CSVDocument {

    // MARK: - Write

    /// Records to text. Nothing here inspects the shape — a ragged table encodes as a ragged table,
    /// because the caller's rows are the caller's business.
    public static func encode(_ records: [[String]]) -> String {
        guard !records.isEmpty else { return "" }
        return records
            .map { $0.map(field(_:)).joined(separator: ",") }
            .joined(separator: "\r\n") + "\r\n"
    }

    /// One field, quoted only where RFC 4180 requires it.
    static func field(_ value: String) -> String {
        guard value.contains(where: { $0 == "," || $0 == "\"" || $0 == "\n" || $0 == "\r" }) else {
            return value
        }
        return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\""
    }

    // MARK: - Read

    /// Text to records — a single character scan, because a line-based reader cannot see a newline
    /// inside a quoted field, and a quoted field holding a card body is the whole reason this format
    /// can carry bodies at all.
    public static func decode(_ text: String) -> [[String]] {
        let characters = Array(stripBOM(text))
        var records: [[String]] = []
        var record: [String] = []
        var field = ""
        var inQuotes = false
        var index = 0

        func endField() {
            record.append(field)
            field = ""
        }

        func endRecord() {
            endField()
            records.append(record)
            record = []
        }

        while index < characters.count {
            let character = characters[index]

            if inQuotes {
                if character == "\"" {
                    // A doubled quote is one literal quote; a lone one closes the field.
                    if index + 1 < characters.count, characters[index + 1] == "\"" {
                        field.append("\"")
                        index += 2
                    } else {
                        inQuotes = false
                        index += 1
                    }
                } else {
                    field.append(character)
                    index += 1
                }
                continue
            }

            switch character {
            case "\"":
                // A quote that *opens* a field opens a quoted field — the shape the writer produces.
                // One appearing later is literal text (`say "hi"` written unquoted by a spreadsheet
                // that saw no reason to escape it), which is worth reading rather than refusing.
                if field.isEmpty { inQuotes = true } else { field.append(character) }
                index += 1
            case ",":
                endField()
                index += 1
            case "\n", "\r", "\r\n":
                // **`"\r\n"` is one `Character`**, not two: Swift's grapheme clustering merges a CR
                // immediately followed by an LF, so a scan over `[Character]` never sees the pair as a
                // sequence and a lookahead for it would never fire. Listing the cluster as its own case
                // is the whole of CRLF handling here — and it is why a bare `"\r"` reaching this switch
                // is genuinely a lone carriage return (old Mac line endings), never half of a pair.
                endRecord()
                index += 1
            default:
                field.append(character)
                index += 1
            }
        }

        // A final record with no terminator still counts; a terminator with nothing after it does not.
        if !record.isEmpty || !field.isEmpty {
            endRecord()
        }
        return records
    }

    private static func stripBOM(_ text: String) -> String {
        text.hasPrefix("\u{FEFF}") ? String(text.dropFirst()) : text
    }
}
