//! The reported statement (extensions/opportunity-report.md §9): what a platform reports it
//! counted, in a documented JSON form (§9.1) or CSV form (§9.2), held to §9.3's rules. It is an
//! input and never evidence: a check names it by the digest of its bytes.

use serde_json::{Map, Value};

/// The forms (§10.1 `form`).
pub mod form {
    pub const JSON: u8 = 1;
    pub const CSV: u8 = 2;

    pub fn name(f: u8) -> &'static str {
        match f {
            JSON => "json",
            CSV => "csv",
            _ => "?",
        }
    }

    pub fn code(s: &str) -> Option<u8> {
        match s {
            "json" => Some(JSON),
            "csv" => Some(CSV),
            _ => None,
        }
    }
}

/// The bases of the reported revenue (§9.1).
pub mod basis {
    pub const GROSS: u8 = 1;
    pub const SHARE: u8 = 2;

    pub fn name(b: u8) -> &'static str {
        match b {
            GROSS => "gross",
            SHARE => "share",
            _ => "?",
        }
    }

    pub fn code(s: &str) -> Option<u8> {
        match s {
            "gross" => Some(GROSS),
            "share" => Some(SHARE),
            _ => None,
        }
    }
}

/// A day's figures: the day as written, the start of its span, and its figures.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Day {
    pub day: String,
    pub start: u64,
    pub impressions: u64,
    pub revenue: u64,
}

/// A programme's figures.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Programme {
    pub asset: Vec<u8>,
    pub impressions: u64,
    pub revenue: u64,
}

/// A reported statement, read.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Reported {
    pub channel: Vec<u8>,
    pub start: u64,
    pub end: u64,
    pub currency: [u8; 3],
    pub basis: u8,
    pub impressions: u64,
    pub revenue: u64,
    pub days: Vec<Day>,
    pub programmes: Vec<Programme>,
}

/// A day's span, in microseconds.
pub const DAY_US: u64 = 86_400_000_000;

fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = y.div_euclid(400);
    let yoe = y - era * 400;
    let mp = (m + 9) % 12;
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

/// The start of a day written `YYYY-MM-DD` (§2), a date of the Gregorian calendar from 1970 to
/// 9999, in microseconds since 1970.
pub fn day_start(s: &str) -> Option<u64> {
    let b = s.as_bytes();
    let digits = |r: std::ops::Range<usize>| -> Option<i64> { b[r].iter().try_fold(0i64, |a, c| c.is_ascii_digit().then(|| a * 10 + (c - b'0') as i64)) };
    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
        return None;
    }
    let (y, m, d) = (digits(0..4)?, digits(5..7)?, digits(8..10)?);
    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
    let month_days = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    if !(1970..=9999).contains(&y) || !(1..=12).contains(&m) || d < 1 || d > month_days[(m - 1) as usize] {
        return None;
    }
    Some(days_from_civil(y, m, d) as u64 * DAY_US)
}

/// A JSON integer from 0 to 2^64 − 1.
fn int(v: &Value) -> Option<u64> {
    v.as_u64()
}

fn members(o: &Map<String, Value>, required: &[&str], optional: &[&str]) -> bool {
    required.iter().all(|k| o.contains_key(*k)) && o.keys().all(|k| required.contains(&k.as_str()) || optional.contains(&k.as_str()))
}

fn from_json(v: &Value) -> Option<Reported> {
    let o = v.as_object()?;
    if !members(o, &["channel", "start", "end", "currency", "basis", "impressions", "revenue"], &["days", "programmes"]) {
        return None;
    }
    let mut days = Vec::new();
    let list = |k: &str| -> Option<&[Value]> { o.get(k).map_or(Some(&[][..]), |v| v.as_array().map(Vec::as_slice)) };
    for d in list("days")? {
        let d = d.as_object()?;
        if !members(d, &["day", "impressions", "revenue"], &[]) {
            return None;
        }
        let day = d["day"].as_str()?.to_string();
        days.push(Day { start: day_start(&day)?, day, impressions: int(&d["impressions"])?, revenue: int(&d["revenue"])? });
    }
    let mut programmes = Vec::new();
    for p in list("programmes")? {
        let p = p.as_object()?;
        if !members(p, &["asset", "impressions", "revenue"], &[]) {
            return None;
        }
        programmes.push(Programme { asset: p["asset"].as_str()?.as_bytes().to_vec(), impressions: int(&p["impressions"])?, revenue: int(&p["revenue"])? });
    }
    Some(Reported {
        channel: o["channel"].as_str()?.as_bytes().to_vec(),
        start: int(&o["start"])?,
        end: int(&o["end"])?,
        currency: o["currency"].as_str()?.as_bytes().try_into().ok()?,
        basis: basis::code(o["basis"].as_str()?)?,
        impressions: int(&o["impressions"])?,
        revenue: int(&o["revenue"])?,
        days,
        programmes,
    })
}

/// RFC 4180 records (§9.2): each ended by CRLF or LF, the last line break optional.
fn records(text: &str) -> Option<Vec<Vec<String>>> {
    let b = text.as_bytes();
    let n = b.len();
    let mut out = Vec::new();
    let mut p = 0;
    if n == 0 {
        return None;
    }
    loop {
        let mut rec = Vec::new();
        loop {
            let mut f = Vec::new();
            if p < n && b[p] == b'"' {
                p += 1;
                loop {
                    if p >= n {
                        return None;
                    }
                    if b[p] == b'"' {
                        if p + 1 < n && b[p + 1] == b'"' {
                            f.push(b'"');
                            p += 2;
                        } else {
                            p += 1;
                            break;
                        }
                    } else {
                        f.push(b[p]);
                        p += 1;
                    }
                }
            } else {
                while p < n && !matches!(b[p], b',' | b'\r' | b'\n') {
                    if b[p] == b'"' {
                        return None;
                    }
                    f.push(b[p]);
                    p += 1;
                }
            }
            rec.push(String::from_utf8(f).ok()?);
            if p < n && b[p] == b',' {
                p += 1;
                continue;
            }
            break;
        }
        out.push(rec);
        if p == n {
            break;
        }
        match b[p] {
            b'\r' if p + 1 < n && b[p + 1] == b'\n' => p += 2,
            b'\n' => p += 1,
            _ => return None,
        }
        if p == n {
            break;
        }
    }
    Some(out)
}

/// An integer of §9.2: `0`, or a digit 1 to 9 and digits, at most 2^64 − 1.
fn csv_int(s: &str) -> Option<u64> {
    let b = s.as_bytes();
    if b.is_empty() || !b.iter().all(u8::is_ascii_digit) || (b.len() > 1 && b[0] == b'0') {
        return None;
    }
    s.parse().ok()
}

fn from_csv(text: &str) -> Option<Reported> {
    let recs = records(text)?;
    if recs.iter().any(|r| r.len() != 4) {
        return None;
    }
    let mut it = recs.iter();
    if it.next()? != &["scope", "key", "impressions", "revenue"] {
        return None;
    }
    let mut meta = |name: &str| -> Option<String> {
        let r = it.next()?;
        (r[0] == name && r[2].is_empty() && r[3].is_empty()).then(|| r[1].clone())
    };
    let channel = meta("channel")?;
    let start = csv_int(&meta("start")?)?;
    let end = csv_int(&meta("end")?)?;
    let currency = meta("currency")?;
    let basis_ = meta("basis")?;
    let rest: Vec<&Vec<String>> = it.collect();
    let (period, rows) = rest.split_first()?;
    if period[0] != "period" || !period[1].is_empty() {
        return None;
    }
    let mut days = Vec::new();
    let mut programmes = Vec::new();
    let mut in_programmes = false;
    for r in rows {
        let (impressions, revenue) = (csv_int(&r[2])?, csv_int(&r[3])?);
        match r[0].as_str() {
            "day" if !in_programmes => days.push(Day { start: day_start(&r[1])?, day: r[1].clone(), impressions, revenue }),
            "programme" => {
                in_programmes = true;
                programmes.push(Programme { asset: r[1].as_bytes().to_vec(), impressions, revenue });
            }
            _ => return None,
        }
    }
    Some(Reported {
        channel: channel.into_bytes(),
        start,
        end,
        currency: currency.as_bytes().try_into().ok()?,
        basis: basis::code(&basis_)?,
        impressions: csv_int(&period[2])?,
        revenue: csv_int(&period[3])?,
        days,
        programmes,
    })
}

/// §9.3's rules, short of the binding to the contract and the report.
fn rules(r: &Reported) -> bool {
    let text = |t: &[u8]| (1..=1024).contains(&t.len());
    text(&r.channel)
        && r.currency.iter().all(u8::is_ascii_uppercase)
        && r.start < r.end
        && r.days.iter().all(|d| r.start <= d.start && d.start.checked_add(DAY_US).is_some_and(|e| e <= r.end))
        && r.days.windows(2).all(|w| w[0].start < w[1].start)
        && r.programmes.iter().all(|p| text(&p.asset))
        && r.programmes.windows(2).all(|w| w[0].asset < w[1].asset)
}

/// Read a reported statement of a form (§9); `None` is `reported_invalid`.
pub fn read(form_: u8, text: &str) -> Option<Reported> {
    let r = match form_ {
        form::JSON => from_json(&serde_json::from_str(text).ok()?)?,
        form::CSV => from_csv(text)?,
        _ => return None,
    };
    rules(&r).then_some(r)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn days() {
        assert_eq!(day_start("1970-01-01"), Some(0));
        assert_eq!(day_start("2026-10-16"), Some(1_792_108_800_000_000));
        assert_eq!(day_start("2024-02-29"), Some(19_782 * DAY_US));
        assert_eq!(day_start("2026-02-29"), None);
        assert_eq!(day_start("1969-12-31"), None);
        assert_eq!(day_start("2026-1-016"), None);
        assert_eq!(day_start("2026-13-01"), None);
    }

    #[test]
    fn the_two_forms_read_alike() {
        let json = r#"{"channel":"fast-9","start":0,"end":172800000000,"currency":"EUR","basis":"share","impressions":5,"revenue":7,
            "days":[{"day":"1970-01-01","impressions":2,"revenue":3},{"day":"1970-01-02","impressions":3,"revenue":4}],
            "programmes":[{"asset":"a,\"b\"","impressions":5,"revenue":7}]}"#;
        let csv = "scope,key,impressions,revenue\r\nchannel,fast-9,,\r\nstart,0,,\r\nend,172800000000,,\r\ncurrency,EUR,,\r\nbasis,share,,\r\nperiod,,5,7\r\nday,1970-01-01,2,3\nday,1970-01-02,3,4\nprogramme,\"a,\"\"b\"\"\",5,7";
        let a = read(form::JSON, json).expect("the JSON form reads");
        assert_eq!(read(form::CSV, csv), Some(a));
    }

    #[test]
    fn what_does_not_read() {
        let base = "scope,key,impressions,revenue\nchannel,fast-9,,\nstart,0,,\nend,86400000000,,\ncurrency,EUR,,\nbasis,gross,,\nperiod,,5,7\n";
        assert!(read(form::CSV, base).is_some());
        assert!(read(form::CSV, &base.replace("period,,5", "period,,05")).is_none());
        assert!(read(form::CSV, &base.replace("basis,gross", "basis,net")).is_none());
        assert!(read(form::CSV, &format!("{base}day,1970-01-02,1,1\n")).is_none(), "a day past the period");
        assert!(read(form::CSV, &format!("{base}programme,b,1,1\nprogramme,a,1,1\n")).is_none(), "programmes out of order");
        assert!(read(form::CSV, &format!("{base}programme,a,1,1\nday,1970-01-01,1,1\n")).is_none(), "a day after a programme");
        assert!(read(form::CSV, &base.replace("fast-9", "fa\"st")).is_none(), "a bare quote");
        assert!(read(form::CSV, &format!("{base}\n")).is_none(), "an empty record");
        assert!(read(form::JSON, r#"{"channel":"x","start":0,"end":1,"currency":"EUR","basis":"gross","impressions":1.0,"revenue":1}"#).is_none());
        assert!(read(form::JSON, r#"{"channel":"x","start":0,"end":1,"currency":"EUR","basis":"gross","impressions":1,"revenue":1,"note":""}"#).is_none());
    }
}
