#!/usr/bin/env python3
"""Read-only exact before/after CSV comparison; policy is user-supplied, not marketplace validation."""
import argparse
import csv
import io
import json
import sys
from pathlib import Path

MAX_BYTES = 10 * 1024 * 1024
MAX_ROWS = 100000
MAX_COLUMNS = 256
MAX_FIELD = 1024 * 1024
MAX_CHANGES = 10000


class InputError(Exception):
    def __init__(self, kind, **details):
        self.report = {"status": "input_error", "comparison_completed": False,
                       "error": {"kind": kind, **details}}


def names_ok(names):
    return bool(names) and len(names) == len(set(names)) and all(n.strip() for n in names)


def read_table(path, side, key, columns, delimiter):
    try:
        if not path.is_file():
            raise InputError("not_regular_file", side=side)
        with path.open("rb") as stream:
            data = stream.read(MAX_BYTES + 1)
        if len(data) > MAX_BYTES:
            raise InputError("byte_limit", side=side)
        text = data.decode("utf-8-sig")
    except (OSError, UnicodeError):
        raise InputError("read_or_encoding_error", side=side) from None
    reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter, strict=True)
    try:
        headers = next(reader, None)
        if not headers or not names_ok(headers):
            raise InputError("invalid_header", side=side)
        if len(headers) > MAX_COLUMNS:
            raise InputError("column_limit", side=side)
        if any(n not in headers for n in [key] + columns):
            raise InputError("missing_column", side=side)
        key_index = headers.index(key)
        indexes = [headers.index(n) for n in columns]
        rows = {}
        for record, row in enumerate(reader, 2):
            if record > MAX_ROWS + 1:
                raise InputError("row_limit", side=side)
            if len(row) != len(headers):
                raise InputError("row_width", side=side, record=record)
            identity = row[key_index]
            if not identity.strip():
                raise InputError("empty_key", side=side, record=record)
            if any(ord(c) < 32 or ord(c) == 127 for c in identity):
                raise InputError("invalid_key", side=side, record=record)
            if identity in rows:
                raise InputError("duplicate_key", side=side, record=record,
                                 first_record=rows[identity][0])
            rows[identity] = (record, tuple(row[i] for i in indexes))
    except csv.Error:
        raise InputError("csv_parse_or_field_limit", side=side) from None
    return headers, rows


def compare(before, after, key, columns, allow, delimiter=","):
    if (not key.strip() or not names_ok(columns) or key in columns
            or len(allow) != len(set(allow)) or not set(allow) <= set(columns)):
        raise InputError("invalid_configuration")
    if len(delimiter) != 1 or delimiter in '\r\n\x00"':
        raise InputError("invalid_delimiter")
    old_limit = csv.field_size_limit(MAX_FIELD)
    try:
        headers, old = read_table(before, "before", key, columns, delimiter)
        new_headers, new = read_table(after, "after", key, columns, delimiter)
    finally:
        csv.field_size_limit(old_limit)
    if set(headers) != set(new_headers):
        raise InputError("incompatible_headers")
    counts = {k: 0 for k in ["allowed_change", "forbidden_change", "added_product", "removed_product"]}
    changes = []

    def event(kind, **details):
        if len(changes) >= MAX_CHANGES:
            raise InputError("change_limit")
        counts[kind] += 1
        changes.append({"kind": kind, **details})

    for identity, (old_record, values) in old.items():
        if identity not in new:
            event("removed_product", before_record=old_record)
            continue
        new_record, new_values = new[identity]
        for column, was, now in zip(columns, values, new_values):
            if was != now:
                event("allowed_change" if column in allow else "forbidden_change",
                      before_record=old_record, after_record=new_record, column=column)
    for identity, (record, _) in new.items():
        if identity not in old:
            event("added_product", after_record=record)
    return {"status": "changes" if changes else "no_changes", "comparison_completed": True,
            "before_rows": len(old), "after_rows": len(new), "matched_rows": len(old.keys() & new.keys()),
            "identity": "exact_string_no_trim", "compared_columns": columns,
            "not_checked_columns": [n for n in headers if n != key and n not in columns],
            "counts": counts, "changes": changes,
            "scope": "selected_fields_and_product_identity_not_import_permission"}


class StrictParser(argparse.ArgumentParser):
    def error(self, message):
        print(json.dumps({"status": "input_error", "comparison_completed": False,
                          "error": {"kind": "invalid_arguments"}}))
        self.exit(2)


class Once(argparse.Action):
    def __call__(self, parser, namespace, values, option_string=None):
        seen = getattr(namespace, "_options_seen", set())
        if self.dest in seen:
            parser.error("repeated option")
        seen.add(self.dest)
        namespace._options_seen = seen
        setattr(namespace, self.dest, values)


def main():
    parser = StrictParser(description=__doc__, allow_abbrev=False)
    parser.add_argument("before", type=Path)
    parser.add_argument("after", type=Path)
    parser.add_argument("--key", required=True, action=Once)
    parser.add_argument("--columns", nargs="+", required=True, action=Once)
    parser.add_argument("--allow", nargs="*", default=[], action=Once)
    parser.add_argument("--delimiter", default=",", action=Once)
    args = parser.parse_args()
    try:
        result = compare(args.before, args.after, args.key, args.columns, args.allow, args.delimiter)
    except InputError as error:
        print(json.dumps(error.report, ensure_ascii=False))
        return 2
    print(json.dumps(result, ensure_ascii=False))
    return 1 if result["changes"] else 0


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