"""Byte-compare Workato filebox versions against DRIP (dr-connectors) staging versions.

Usage:
    python compare_files.py <manifest.json> [--report report.md]
    python compare_files.py --pair <workato.csv> <staging.csv>

manifest.json is a list of pairs. Relative paths resolve against the manifest's own folder:
    [{"name": "Shifts / Jarana Aventura / 2026-09-16",
      "workato": "wk/617001_39532879.csv", "workato_version": 39532879,
      "staging": "st/700001_39800001.csv", "staging_version": 39800001}, ...]

The pass rule is BYTE-IDENTICAL (sha256 equal). On any mismatch the script explains
WHY the bytes differ (encoding/BOM, line endings, trailing newline, header, rows,
cells, quoting-only), so the operator can decide what to change in the connector.
It never modifies anything. Exit code 0 = every pair identical, 1 = any mismatch/missing or no pairs.
"""

from __future__ import annotations

import argparse
import csv
import hashlib
import io
import json
import sys
from collections import Counter
from pathlib import Path

MAX_EXAMPLES = 10
MAX_VALUE_CHARS = 80
# Workato/connector CSVs can carry large cells (embedded JSON); the default 128 KiB limit raises
# csv.Error. 2**27 stays inside a 32-bit C long, so it is safe on Windows too.
csv.field_size_limit(2**27)


def sha256(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def line_ending(data: bytes) -> str:
    crlf = data.count(b"\r\n")
    lf = data.count(b"\n") - crlf
    if crlf and lf:
        return f"mixed (CRLF={crlf}, LF={lf})"
    return "CRLF" if crlf else ("LF" if lf else "none")


def decode(data: bytes) -> tuple[str, str]:
    if data.startswith(b"\xef\xbb\xbf"):
        return data[3:].decode("utf-8", errors="replace"), "utf-8-sig (BOM)"
    try:
        return data.decode("utf-8"), "utf-8"
    except UnicodeDecodeError:
        return data.decode("latin-1"), "NOT utf-8 (decoded as latin-1)"


def parse(text: str) -> tuple[list[list[str]] | None, str | None]:
    """Parsed rows, or (None, reason) when the text isn't parseable CSV."""
    try:
        return list(csv.reader(io.StringIO(text, newline=""), strict=True)), None
    except csv.Error as exc:
        return None, str(exc)


def short(value: object) -> str:
    """repr() of an example value, truncated so one huge cell can't flood the report."""
    text = repr(value)
    if len(text) <= MAX_VALUE_CHARS:
        return text
    return f"{text[:MAX_VALUE_CHARS]}... ({len(str(value))} chars)"


def multiset_diff(a: list, b: list) -> tuple[list, list]:
    """Items only in a / only in b, respecting multiplicity."""
    ca, cb = Counter(a), Counter(b)
    return list((ca - cb).elements()), list((cb - ca).elements())


def diagnose(wk: bytes, st: bytes) -> list[str]:
    notes: list[str] = []
    wk_text, wk_enc = decode(wk)
    st_text, st_enc = decode(st)
    if wk_enc != st_enc:
        notes.append(f"encoding: workato={wk_enc}, staging={st_enc}")
    if line_ending(wk) != line_ending(st):
        notes.append(
            f"line endings: workato={line_ending(wk)}, staging={line_ending(st)}"
        )
    wk_nl, st_nl = wk.endswith(b"\n"), st.endswith(b"\n")
    if wk_nl != st_nl:
        notes.append(f"trailing newline: workato={wk_nl}, staging={st_nl}")

    wk_rows, wk_err = parse(wk_text)
    st_rows, st_err = parse(st_text)
    for side, err in (("workato", wk_err), ("staging", st_err)):
        if err:
            notes.append(
                f"CSV unparseable ({side}): {err}; no header/row/cell diagnosis"
            )
    if wk_rows is None or st_rows is None:
        return notes

    wk_head, st_head = (wk_rows[0] if wk_rows else []), (st_rows[0] if st_rows else [])
    if wk_head != st_head:
        missing, extra = multiset_diff(wk_head, st_head)
        if missing or extra:
            notes.append(
                f"header differs: missing in staging={missing}, extra in staging={extra}"
            )
        else:
            notes.append(
                f"header column ORDER differs: workato={wk_head} staging={st_head}"
            )

    wk_body, st_body = wk_rows[1:], st_rows[1:]
    if len(wk_body) != len(st_body):
        # A positional diff would cascade after the first inserted/deleted row, so report the
        # multiset difference instead.
        notes.append(f"row count: workato={len(wk_body)}, staging={len(st_body)}")
        only_wk, only_st = multiset_diff(
            list(map(tuple, wk_body)), list(map(tuple, st_body))
        )
        notes.append(
            f"rows only in workato: {len(only_wk)}, rows only in staging: {len(only_st)}"
        )
        for label, rows in (("only in workato", only_wk), ("only in staging", only_st)):
            for row in rows[:MAX_EXAMPLES]:
                notes.append(f"  {label}: {short(list(row))}")
    elif wk_body != st_body and Counter(map(tuple, wk_body)) == Counter(
        map(tuple, st_body)
    ):
        notes.append("same rows, different ORDER")
    else:
        cell_diffs = []
        for i, (a, b) in enumerate(zip(wk_body, st_body), start=2):
            for j in range(max(len(a), len(b))):
                va = a[j] if j < len(a) else "<missing>"
                vb = b[j] if j < len(b) else "<missing>"
                if va != vb:
                    col = wk_head[j] if j < len(wk_head) else f"#{j}"
                    cell_diffs.append((i, col, va, vb))
        if cell_diffs:
            by_col = Counter(col for _, col, _, _ in cell_diffs)
            notes.append(
                f"cell diffs: {len(cell_diffs)} total, by column {dict(by_col)}"
            )
            for i, col, va, vb in cell_diffs[:MAX_EXAMPLES]:
                notes.append(
                    f"  row {i} [{col}]: workato={short(va)} staging={short(vb)}"
                )

    if wk_rows == st_rows and not notes:
        notes.append(
            "parsed CSV values are IDENTICAL - difference is quoting/escaping only "
            '(e.g. Workato writes empty cells as ""). Match Workato\'s quoting in the connector.'
        )
    elif wk_rows == st_rows:
        notes.append(
            "parsed CSV values are identical apart from the byte-level notes above"
        )
    if not notes:
        notes.append(
            "bytes differ but no specific cause found - inspect with a hex diff"
        )
    return notes


def compare(entry: dict, wk_path: Path, st_path: Path) -> dict:
    res = {
        "name": entry.get("name", wk_path.name),
        "workato": str(wk_path),
        "staging": str(st_path),
        "workato_version": entry.get("workato_version", "-"),
        "staging_version": entry.get("staging_version", "-"),
    }
    for side, p in (("workato", wk_path), ("staging", st_path)):
        if not p.is_file():
            res.update(status="MISSING", notes=[f"{side} file not found: {p}"])
            return res
    wk, st = wk_path.read_bytes(), st_path.read_bytes()
    res.update(
        workato_sha256=sha256(wk),
        staging_sha256=sha256(st),
        workato_bytes=len(wk),
        staging_bytes=len(st),
    )
    if wk == st:
        res.update(status="IDENTICAL", notes=[])
    else:
        res.update(status="DIFFERENT", notes=diagnose(wk, st))
    return res


def cell(value: object) -> str:
    return str(value).replace("|", r"\|")


def render_report(results: list[dict]) -> str:
    ok = sum(r["status"] == "IDENTICAL" for r in results)
    out = [f"# Workato vs DRIP comparison: {ok}/{len(results)} identical", ""]
    out.append(
        "| Pair | Status | Workato version | Staging version | Workato bytes | Staging bytes "
        "| sha256 (workato / staging) |"
    )
    out.append("|---|---|---|---|---|---|---|")
    for r in results:
        out.append(
            f"| {cell(r['name'])} | {r['status']} | {r['workato_version']} | {r['staging_version']} "
            f"| {r.get('workato_bytes', '-')} | {r.get('staging_bytes', '-')} "
            f"| {r.get('workato_sha256', '-')[:12]} / {r.get('staging_sha256', '-')[:12]} |"
        )
    for r in results:
        if r["notes"]:
            out += ["", f"## {r['name']} - {r['status']}", ""]
            out += [
                f"- {n}" if not n.startswith("  ") else f"  - {n.strip()}"
                for n in r["notes"]
            ]
    return "\n".join(out) + "\n"


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("manifest", nargs="?")
    ap.add_argument("--pair", nargs=2, metavar=("WORKATO", "STAGING"))
    ap.add_argument("--report")
    args = ap.parse_args()
    if args.pair:
        base = Path.cwd()
        pairs = [
            {
                "name": Path(args.pair[0]).name,
                "workato": args.pair[0],
                "staging": args.pair[1],
            }
        ]
    elif args.manifest:
        base = Path(args.manifest).resolve().parent
        pairs = json.loads(Path(args.manifest).read_text(encoding="utf-8"))
    else:
        ap.error("pass a manifest or --pair")

    def resolve(p: str) -> Path:
        return Path(p) if Path(p).is_absolute() else base / p

    results = [compare(p, resolve(p["workato"]), resolve(p["staging"])) for p in pairs]
    report = render_report(results)
    if args.report:
        Path(args.report).write_text(report, encoding="utf-8")
    sys.stdout.write(report)
    return 0 if results and all(r["status"] == "IDENTICAL" for r in results) else 1


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