//! Corpus validity and round-trip tests.
//!
//! These assert what the corpus exists to prove: every expected JSON is valid,
//! the array-vs-object outlier holds, and a conforming CSV parser turns each
//! CSV into its expected JSON.
//!
//! The parser here follows the corpus rules: comma field separator, double
//! quote for quoting, doubled quotes escape to one quote, CR/LF/CRLF inside a
//! quoted field are literal data, record separators are LF or CRLF, every value
//! is a string, no type coercion. The first record is the header.

use csv_spectrum::{spectrum, Sample};
use serde_json::{Map, Value};

#[test]
fn every_expected_json_is_valid() {
    for s in spectrum() {
        let parsed: Result<Value, _> = serde_json::from_slice(s.json);
        assert!(parsed.is_ok(), "{}.json is not valid JSON", s.name);
    }
}

/// Eleven fixtures are arrays of row objects. `location_coordinates` is a lone
/// object. Pin both so a refactor cannot silently normalize the outlier.
#[test]
fn shapes_match_array_except_the_outlier() {
    for s in spectrum() {
        let value: Value = serde_json::from_slice(s.json).unwrap();
        if s.name == "location_coordinates" {
            assert!(value.is_object(), "location_coordinates must be an object");
        } else {
            assert!(value.is_array(), "{} must be an array", s.name);
        }
    }
}

/// Each CSV parses to its expected JSON.
///
/// `location_coordinates` is skipped here. Its expected JSON anonymizes the
/// phone number, so the parsed CSV cannot equal it. That divergence is checked
/// on its own below.
#[test]
fn csv_parses_to_expected_json() {
    for s in spectrum() {
        if s.name == "location_coordinates" {
            continue;
        }
        let rows = parse_csv(s.csv);
        let got = rows_to_value(s, &rows);
        let want: Value = serde_json::from_slice(s.json).unwrap();
        assert_eq!(got, want, "parse mismatch for {}", s.name);
    }
}

/// `location_coordinates` parses to its expected JSON in every field except the
/// phone number, which the corpus anonymizes on purpose.
#[test]
fn location_coordinates_matches_except_phone() {
    let s = find("location_coordinates");
    let rows = parse_csv(s.csv);
    let mut got = rows_to_value(&s, &rows);
    let want: Value = serde_json::from_slice(s.json).unwrap();

    // The parsed phone is the real CSV cell. The expected JSON masks it.
    assert_eq!(
        got["Contact Phone Number"],
        Value::String("2095257564".into())
    );
    assert_eq!(
        want["Contact Phone Number"],
        Value::String("1234567890".into())
    );

    // Overwrite the masked field, then the rest must match byte for byte.
    got["Contact Phone Number"] = Value::String("1234567890".into());
    assert_eq!(got, want);
}

/// The CRLF-in-field case keeps the carriage return inside the value. The LF
/// sibling does not.
#[test]
fn crlf_inside_field_is_kept() {
    let crlf: Value = serde_json::from_slice(find("newlines_crlf").json).unwrap();
    let field = crlf[1]["a"].as_str().unwrap();
    assert_eq!(field, "Once upon \r\na time");

    let lf: Value = serde_json::from_slice(find("newlines").json).unwrap();
    let field = lf[1]["a"].as_str().unwrap();
    assert_eq!(field, "Once upon \na time");
}

/// Empty quoted fields are empty strings, not null or missing keys.
#[test]
fn empty_quoted_fields_are_empty_strings() {
    for name in ["empty", "empty_crlf"] {
        let value: Value = serde_json::from_slice(find(name).json).unwrap();
        let first = &value[0];
        assert_eq!(first["b"], Value::String(String::new()));
        assert_eq!(first["c"], Value::String(String::new()));
    }
}

/// A comma inside a quoted field is literal data. The field does not split, so
/// the row keeps its column count and the comma stays inside one value.
#[test]
fn quoted_comma_does_not_split_field() {
    let value: Value = serde_json::from_slice(find("comma_in_quotes").json).unwrap();
    let row = &value[0];
    assert_eq!(row["address"], Value::String("120 any st.".into()));
    assert_eq!(row["city"], Value::String("Anytown, WW".into()));
    assert_eq!(row["zip"], Value::String("08123".into()));
    // Five header keys, five values. A split on the quoted comma would add one.
    assert_eq!(row.as_object().unwrap().len(), 5);
}

/// Doubled quotes inside a quoted field collapse to one quote. Pin the exact
/// decoded values so the case holds without relying on the round-trip parser.
#[test]
fn doubled_quotes_collapse_to_one() {
    let escaped: Value = serde_json::from_slice(find("escaped_quotes").json).unwrap();
    assert_eq!(escaped[0]["b"], Value::String(r#"ha "ha" ha"#.into()));

    let mixed: Value = serde_json::from_slice(find("quotes_and_newlines").json).unwrap();
    assert_eq!(mixed[0]["b"], Value::String("ha \n\"ha\" \nha".into()));
}

/// A JSON document held in one quoted cell survives intact. The cell's doubled
/// quotes collapse to single quotes, and the brackets, colons, and commas
/// inside stay literal. The decoded cell is itself valid JSON.
#[test]
fn embedded_json_cell_round_trips() {
    let value: Value = serde_json::from_slice(find("json").json).unwrap();
    let cell = value[0]["val"].as_str().unwrap();
    assert_eq!(cell, r#"{"type": "Point", "coordinates": [102.0, 0.5]}"#);

    // The cell parses as JSON and the inner coordinates are numbers, not strings.
    let inner: Value = serde_json::from_str(cell).unwrap();
    assert_eq!(inner["type"], Value::String("Point".into()));
    assert_eq!(inner["coordinates"][0], Value::from(102.0));
    assert_eq!(inner["coordinates"][1], Value::from(0.5));
}

/// No numeric coercion. Leading zeros survive and every leaf is a string.
#[test]
fn values_stay_strings() {
    let value: Value = serde_json::from_slice(find("comma_in_quotes").json).unwrap();
    assert_eq!(value[0]["zip"], Value::String("08123".into()));

    for s in spectrum() {
        let value: Value = serde_json::from_slice(s.json).unwrap();
        for leaf in leaves(&value) {
            assert!(leaf.is_string(), "{} has a non-string leaf", s.name);
        }
    }
}

// --- helpers ---------------------------------------------------------------

fn find(name: &str) -> Sample {
    *spectrum().iter().find(|s| s.name == name).unwrap()
}

/// Turns parsed rows into the JSON shape the corpus uses. Every fixture is an
/// array of header-keyed objects except `location_coordinates`, which is a bare
/// object built from its single data row.
fn rows_to_value(sample: &Sample, rows: &[Vec<String>]) -> Value {
    let header = &rows[0];
    let object = |row: &[String]| -> Value {
        let mut map = Map::new();
        for (key, val) in header.iter().zip(row) {
            map.insert(key.clone(), Value::String(val.clone()));
        }
        Value::Object(map)
    };
    if sample.name == "location_coordinates" {
        object(&rows[1])
    } else {
        Value::Array(rows[1..].iter().map(|r| object(r)).collect())
    }
}

/// Parses CSV bytes into rows of string fields per the corpus rules.
fn parse_csv(bytes: &[u8]) -> Vec<Vec<String>> {
    let text = std::str::from_utf8(bytes).expect("corpus CSV is valid UTF-8");
    let mut rows = Vec::new();
    let mut row = Vec::new();
    let mut field = String::new();
    let mut chars = text.chars().peekable();
    let mut in_quotes = false;
    let mut field_started = false;

    while let Some(c) = chars.next() {
        if in_quotes {
            if c == '"' {
                if chars.peek() == Some(&'"') {
                    chars.next();
                    field.push('"');
                } else {
                    in_quotes = false;
                }
            } else {
                field.push(c);
            }
            continue;
        }
        match c {
            '"' if !field_started => {
                in_quotes = true;
                field_started = true;
            }
            ',' => {
                row.push(std::mem::take(&mut field));
                field_started = false;
            }
            '\r' => {
                if chars.peek() == Some(&'\n') {
                    chars.next();
                }
                row.push(std::mem::take(&mut field));
                rows.push(std::mem::take(&mut row));
                field_started = false;
            }
            '\n' => {
                row.push(std::mem::take(&mut field));
                rows.push(std::mem::take(&mut row));
                field_started = false;
            }
            _ => {
                // Any other character is literal field data. This also catches
                // characters that follow a closing quote before the next
                // delimiter, which would be malformed quoting. They are appended
                // as data here. No corpus fixture produces that case, so the
                // round-trip stays correct for every fixture.
                field.push(c);
                field_started = true;
            }
        }
    }

    // Flush the final field and row when the file has no trailing line break.
    if field_started || !field.is_empty() || !row.is_empty() {
        row.push(field);
        rows.push(row);
    }
    rows
}

/// Collects every leaf value in a JSON tree.
fn leaves(value: &Value) -> Vec<&Value> {
    let mut out = Vec::new();
    let mut stack = vec![value];
    while let Some(v) = stack.pop() {
        match v {
            Value::Array(items) => stack.extend(items.iter()),
            Value::Object(map) => stack.extend(map.values()),
            leaf => out.push(leaf),
        }
    }
    out
}
