#!/usr/bin/env python3
"""eotl-check - validate a CSV file against a small JSON schema before it lands.

Usage:
    eotl-check SCHEMA.json DATA.csv [--delimiter ,] [--max-errors 50] [--quiet]

Exit codes:
    0  file is valid
    1  file has validation errors
    2  usage error, unreadable schema or unreadable data

Schema format:
    {
      "columns": [
        {"name": "id",      "type": "int",   "required": true, "unique": true},
        {"name": "amount",  "type": "float", "min": 0},
        {"name": "created", "type": "date"},
        {"name": "status",  "type": "str",   "enum": ["open", "closed"]}
      ],
      "allow_extra_columns": false
    }

Supported types: str, int, float, date (YYYY-MM-DD), datetime (ISO 8601), bool.
"""

import argparse
import csv
import json
import sys
from datetime import date, datetime

__version__ = "0.4.2"

TRUE_VALUES = {"true", "1", "yes", "y"}
FALSE_VALUES = {"false", "0", "no", "n"}


def parse_bool(value):
    v = value.strip().lower()
    if v in TRUE_VALUES:
        return True
    if v in FALSE_VALUES:
        return False
    raise ValueError("not a boolean")


PARSERS = {
    "str": str,
    "int": int,
    "float": float,
    "date": date.fromisoformat,
    "datetime": datetime.fromisoformat,
    "bool": parse_bool,
}


class Report:
    def __init__(self, max_errors):
        self.max_errors = max_errors
        self.errors = []
        self.truncated = False

    def add(self, line, column, message):
        if len(self.errors) >= self.max_errors:
            self.truncated = True
            return
        self.errors.append((line, column, message))


def load_schema(path):
    with open(path, encoding="utf-8") as fh:
        schema = json.load(fh)
    columns = schema.get("columns")
    if not isinstance(columns, list) or not columns:
        raise ValueError("schema must define a non-empty 'columns' list")
    for col in columns:
        if "name" not in col:
            raise ValueError("every column needs a 'name'")
        ctype = col.setdefault("type", "str")
        if ctype not in PARSERS:
            raise ValueError(f"column {col['name']!r}: unknown type {ctype!r}")
    return schema


def check_header(header, schema, report):
    expected = [c["name"] for c in schema["columns"]]
    missing = [n for n in expected if n not in header]
    for name in missing:
        report.add(1, name, "column missing from header")
    if not schema.get("allow_extra_columns", False):
        for name in header:
            if name not in expected:
                report.add(1, name, "unexpected column")
    return not missing


def check_value(raw, col):
    if raw == "":
        if col.get("required", False):
            return "required value is empty"
        return None
    try:
        value = PARSERS[col["type"]](raw)
    except ValueError:
        return f"expected {col['type']}, got {raw!r}"
    if "enum" in col and value not in col["enum"]:
        return f"{raw!r} is not one of {col['enum']}"
    if "min" in col and value < col["min"]:
        return f"{raw} is below minimum {col['min']}"
    if "max" in col and value > col["max"]:
        return f"{raw} is above maximum {col['max']}"
    return None


def validate(schema, data_path, delimiter, report):
    seen = {c["name"]: set() for c in schema["columns"] if c.get("unique")}
    rows = 0
    with open(data_path, newline="", encoding="utf-8-sig") as fh:
        reader = csv.DictReader(fh, delimiter=delimiter)
        if reader.fieldnames is None:
            report.add(1, "-", "file is empty")
            return rows
        if not check_header(reader.fieldnames, schema, report):
            return rows
        for line, row in enumerate(reader, start=2):
            rows += 1
            if None in row:
                report.add(line, "-", "row has more fields than the header")
            for col in schema["columns"]:
                raw = row.get(col["name"]) or ""
                problem = check_value(raw, col)
                if problem:
                    report.add(line, col["name"], problem)
                elif col["name"] in seen and raw != "":
                    if raw in seen[col["name"]]:
                        report.add(line, col["name"], f"duplicate value {raw!r}")
                    seen[col["name"]].add(raw)
    return rows


def main(argv=None):
    ap = argparse.ArgumentParser(prog="eotl-check", description=__doc__.splitlines()[0])
    ap.add_argument("schema")
    ap.add_argument("data")
    ap.add_argument("--delimiter", default=",")
    ap.add_argument("--max-errors", type=int, default=50)
    ap.add_argument("--quiet", action="store_true", help="print only the summary line")
    ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
    args = ap.parse_args(argv)

    try:
        schema = load_schema(args.schema)
    except (OSError, ValueError) as exc:
        print(f"eotl-check: schema: {exc}", file=sys.stderr)
        return 2

    report = Report(args.max_errors)
    try:
        rows = validate(schema, args.data, args.delimiter, report)
    except (OSError, UnicodeDecodeError, csv.Error) as exc:
        print(f"eotl-check: data: {exc}", file=sys.stderr)
        return 2

    if not args.quiet:
        for line, column, message in report.errors:
            print(f"{args.data}:{line}: [{column}] {message}")
        if report.truncated:
            print(f"... stopped after {args.max_errors} errors")
    status = "FAIL" if report.errors else "OK"
    print(f"{status}: {rows} rows checked, {len(report.errors)} errors")
    return 1 if report.errors else 0


if __name__ == "__main__":
    sys.exit(main())
