"""
iscc_scan.py — ISCC content inventory tool.

Workspace-based workflow:
  1. Scan directories individually into a shared workspace
  2. Compile a single CSV with clustering from all scanned data

Commands:
    python3 iscc_scan.py scan    /path/to/content   /path/to/workspace
    python3 iscc_scan.py scan    /other/content     /path/to/workspace
    python3 iscc_scan.py scan    /other/content     /path/to/workspace --batch myBatch
    python3 iscc_scan.py compile /path/to/workspace
    python3 iscc_scan.py compile /path/to/workspace --threshold 15
    python3 iscc_scan.py compile /path/to/workspace --similarity 90
    python3 iscc_scan.py status  /path/to/workspace

Workspace structure:
    workspace/
      scan_log.json              ← tracks scanned directories
      sidecars/
        my-project/              ← batch per scanned directory
          subdir/
            file.pdf.iscc.json
        other-content/
          report.docx.iscc.json
      iscc_inventory.csv         ← generated by 'compile'
"""

import csv
import json
import re
import sys
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path

try:
    import iscc_sdk as idk
except ImportError:
    sys.exit("iscc-sdk not installed. Run: pip install iscc-sdk")

try:
    import iscc_core as ic
except ImportError:
    sys.exit("iscc-core not installed. Run: pip install iscc-core")


SKIP_PREFIXES = (".", "~")
DEFAULT_THRESHOLD = 10
CONTENT_UNIT_BITS = 64 # Without the 8 bit header!
SIDECARS_DIR = "sidecars"
SCAN_LOG = "scan_log.json"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def slugify(text: str) -> str:
    """Turn a path into a safe directory name."""
    text = text.strip("/").replace("\\", "/")
    # Use last two path components for readability
    parts = text.split("/")
    slug = "_".join(parts[-2:]) if len(parts) >= 2 else parts[-1] if parts else "scan"
    slug = re.sub(r"[^\w\-.]", "_", slug)
    slug = re.sub(r"_+", "_", slug).strip("_")
    return slug or "scan"


def load_scan_log(workspace: Path) -> dict:
    log_path = workspace/SCAN_LOG
    if log_path.exists():
        with open(log_path) as f:
            return json.load(f)
    return {"scans": []}


def save_scan_log(workspace: Path, log: dict) -> None:
    log_path = workspace/SCAN_LOG
    with open(log_path, "w", encoding="utf-8") as f:
        json.dump(log, f, indent=2, ensure_ascii=False)


def decompose_units(iscc_code: str) -> dict:
    units = {"meta": "", "content": "", "data": "", "instance": ""}
    try:
        parts = ic.iscc_decompose(iscc_code)
        for part in parts:
            explanation = ic.iscc_explain(part)
            maintype = explanation.split("-")[0].lower()
            if maintype in units:
                units[maintype] = part
    except Exception:
        pass
    return units


def hamming_distance(iscc_a: str, iscc_b: str) -> int | None:
    try:
        return ic.iscc_distance(iscc_a, iscc_b)
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Clustering
# ---------------------------------------------------------------------------

def group_by_exact(rows: list[dict], unit_key: str, group_col: str) -> None:
    buckets = defaultdict(list)
    for i, row in enumerate(rows):
        val = row.get(unit_key, "")
        if val:
            buckets[val].append(i)

    group_id = 0
    for indices in buckets.values():
        if len(indices) < 2:
            continue
        group_id += 1
        for i in indices:
            rows[i][group_col] = group_id


def cluster_by_similarity(rows: list[dict], unit_key: str, cluster_col: str,
                          threshold: int) -> int:
    active = [(i, rows[i][unit_key]) for i in range(len(rows)) if rows[i].get(unit_key)]
    parent = list(range(len(active)))

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    def union(a, b):
        ra, rb = find(a), find(b)
        if ra != rb:
            parent[ra] = rb

    comparisons = 0
    for ai in range(len(active)):
        for bi in range(ai + 1, len(active)):
            dist = hamming_distance(active[ai][1], active[bi][1])
            comparisons += 1
            if dist is not None and dist <= threshold:
                union(ai, bi)

    clusters = defaultdict(list)
    for ai in range(len(active)):
        clusters[find(ai)].append(active[ai][0])

    cluster_id = 0
    for indices in clusters.values():
        if len(indices) < 2:
            continue
        cluster_id += 1
        for row_idx in indices:
            rows[row_idx][cluster_col] = cluster_id

    return comparisons


# ---------------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------------

def cmd_scan(source_dir: Path, workspace: Path, batch_name: str | None = None):
    """Scan a directory and store JSON sidecars in the workspace."""

    source_dir = source_dir.resolve()
    workspace = workspace.resolve()
    workspace.mkdir(parents=True, exist_ok=True)

    # Determine batch name
    if not batch_name:
        batch_name = slugify(str(source_dir))

    # Check if already scanned
    log = load_scan_log(workspace)
    existing = [s for s in log["scans"] if s["source"] == str(source_dir)]
    if existing:
        print(f"\nWarning: {source_dir} was already scanned (batch: {existing[0]['batch']})")
        print(f"  Sidecars will be overwritten.\n")

    sidecar_root = workspace/SIDECARS_DIR/batch_name
    sidecar_root.mkdir(parents=True, exist_ok=True)

    # Collect files
    all_files = []
    for path in sorted(source_dir.rglob("*")):
        if not path.is_file():
            continue
        if any(path.name.startswith(p) for p in SKIP_PREFIXES):
            continue
        if path.suffix.lstrip(".") not in idk.mediatype.SUPPORTED_EXTENSIONS:
            continue
        if path.suffix == ".json" and ".iscc." in path.name:
            continue
        all_files.append(path)

    total = len(all_files)
    print(f"\nScanning {total} files from {source_dir}")
    print(f"Batch: {batch_name}\n")

    processed = 0
    errors = []

    for i, filepath in enumerate(all_files, 1):
        rel_path = filepath.relative_to(source_dir)
        sidecar_dir = sidecar_root/rel_path.parent
        sidecar_path = sidecar_dir/f"{filepath.name}.iscc.json"

        pct = (i / total) * 100
        print(f"[{i}/{total} — {pct:.0f}%] {rel_path}", end="")

        # Skip if sidecar already exists
        if sidecar_path.exists():
            print(" … exists")
            processed += 1
            continue

        # Generate ISCC
        start = time.time()
        try:
            result = idk.code_iscc(str(filepath))
            meta = result.dict(exclude_none=True)
        except Exception as e:
            print(f" … failed ({str(e)[:60]})")
            errors.append({"file": str(rel_path), "error": str(e)})
            continue

        elapsed = time.time() - start

        # Save sidecar
        sidecar_dir.mkdir(parents=True, exist_ok=True)
        meta["_source_path"] = str(rel_path)
        meta["_batch"] = batch_name
        with open(sidecar_path, "w", encoding="utf-8") as f:
            json.dump(meta, f, indent=2, ensure_ascii=False)

        processed += 1
        print(f" … OK ({elapsed:.1f}s)")

    # Update scan log
    log["scans"] = [s for s in log["scans"] if s["source"] != str(source_dir)]
    log["scans"].append({
        "source": str(source_dir),
        "batch": batch_name,
        "scanned_at": datetime.now(timezone.utc).isoformat(),
        "files_found": total,
        "files_processed": processed,
        "errors": len(errors),
    })
    save_scan_log(workspace, log)

    # Error log per batch
    if errors:
        error_path = sidecar_root/"_errors.json"
        with open(error_path, "w", encoding="utf-8") as f:
            json.dump(errors, f, indent=2, ensure_ascii=False)

    print(f"\n{'=' * 60}")
    print(f"Scan complete: {batch_name}")
    print(f"  Processed: {processed}/{total}")
    print(f"  Errors:    {len(errors)}")
    print(f"  Sidecars:  {sidecar_root}/")
    print(f"{'=' * 60}\n")


def cmd_compile(workspace: Path, threshold: int = DEFAULT_THRESHOLD):
    """Read all sidecars from workspace, cluster, write CSV."""

    workspace = workspace.resolve()
    sidecar_root = workspace/SIDECARS_DIR

    if not sidecar_root.exists():
        sys.exit(f"No sidecars found in {workspace}")

    # Collect all sidecar JSONs
    sidecar_files = sorted(sidecar_root.rglob("*.iscc.json"))
    print(f"\nCompiling {len(sidecar_files)} sidecars from {sidecar_root}\n")

    rows = []
    load_errors = 0

    log = load_scan_log(workspace)
    batch_to_source = {s["batch"]: s["source"] for s in log["scans"]}

    for sf in sidecar_files:
        try:
            with open(sf) as f:
                meta = json.load(f)
        except Exception:
            load_errors += 1
            continue

        iscc_code = meta.get("iscc", "")
        units = decompose_units(iscc_code) if iscc_code else {}

        # Reconstruct source path from batch + relative path
        batch = meta.get("_batch", "")
        source_path = meta.get("_source_path", "")
        source_dir = batch_to_source.get(batch, "")
        absolute_path = str(Path(source_dir)/source_path) if source_dir else source_path

        rows.append({
            "batch": batch,
            "source_path": source_path,
            "filename": meta.get("filename", ""),
            "absolute_path": absolute_path,
            "mediatype": meta.get("mediatype", ""),
            "filesize": meta.get("filesize", ""),
            "iscc": iscc_code,
            "iscc_meta_unit": units.get("meta", ""),
            "iscc_content_unit": units.get("content", ""),
            "iscc_data_unit": units.get("data", ""),
            "iscc_instance_unit": units.get("instance", ""),
            "instance_group": "",
            "data_group": "",
            "content_cluster": "",
        })

    if not rows:
        sys.exit("No valid sidecars found.")

    # Cluster
    print(f"Clustering {len(rows)} entries …")

    group_by_exact(rows, "iscc_instance_unit", "instance_group")
    group_by_exact(rows, "iscc_data_unit", "data_group")

    comparisons = cluster_by_similarity(rows, "iscc_content_unit", "content_cluster", threshold)
    print(f"  CONTENT comparisons: {comparisons:,}")

    n_instance = len(set(r["instance_group"] for r in rows if r["instance_group"]))
    n_data = len(set(r["data_group"] for r in rows if r["data_group"]))
    n_content = len(set(r["content_cluster"] for r in rows if r["content_cluster"]))

    # Write CSV
    csv_path = workspace/"iscc_inventory.csv"
    csv_columns = [
        "batch",
        "source_path",
        "filename",
        "absolute_path",
        "mediatype",
        "filesize",
        "iscc",
        "iscc_meta_unit",
        "iscc_content_unit",
        "iscc_data_unit",
        "iscc_instance_unit",
        "instance_group",
        "data_group",
        "content_cluster",
    ]

    with open(csv_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=csv_columns, extrasaction="ignore")
        writer.writeheader()
        for row in rows:
            writer.writerow(row)

    print(f"\n{'=' * 60}")
    print(f"Compile complete.")
    print(f"  Entries:          {len(rows)}")
    if load_errors:
        print(f"  Load errors:      {load_errors}")
    print(f"")
    print(f"  Identical copies: {n_instance} groups  (INSTANCE)")
    print(f"  Near-duplicates:  {n_data} groups  (DATA)")
    print(f"  Similar content:  {n_content} clusters (CONTENT, threshold={threshold})")
    print(f"")
    print(f"  CSV: {csv_path}")
    print(f"{'=' * 60}\n")


def cmd_status(workspace: Path):
    """Show what has been scanned."""

    workspace = workspace.resolve()
    log = load_scan_log(workspace)

    if not log["scans"]:
        print(f"\nNo scans recorded in {workspace}\n")
        return

    print(f"\nWorkspace: {workspace}")
    print(f"Scans: {len(log['scans'])}\n")

    for s in log["scans"]:
        print(f"  [{s['batch']}]")
        print(f"    Source:    {s['source']}")
        print(f"    Scanned:  {s['scanned_at']}")
        print(f"    Files:    {s['files_processed']}/{s['files_found']}")
        if s.get("errors"):
            print(f"    Errors:   {s['errors']}")
        print()


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main():
    import argparse

    parser = argparse.ArgumentParser(
        description="ISCC content inventory tool."
    )

    # parse arguments as {foo}.command
    sub = parser.add_subparsers(dest="command", required=True)

    # scan ##########################
    p_scan = sub.add_parser("scan", help="Scan a directory into the workspace")
    # scan arguments
    p_scan.add_argument("source", type=Path, help="Directory to scan")
    p_scan.add_argument("workspace", type=Path, help="Workspace directory")
    p_scan.add_argument("--batch", type=str, default=None, help="Custom batch name")

    # compile ##########################
    p_compile = sub.add_parser("compile", help="Compile CSV from all scanned data")
    # compile arguments
    p_compile.add_argument("workspace", type=Path, help="Workspace directory")
    # compile accepts either --threshold or --similarity
    clust = p_compile.add_mutually_exclusive_group()
    # --threshold for hamming distance
    clust.add_argument(
        "--threshold", type=int, default=None,
        help=f"Hamming distance threshold for CONTENT clustering (default: {DEFAULT_THRESHOLD})",
    )
    # --similarity for percentage
    clust.add_argument(
        "--similarity", type=float, default=None,
        help="Minimum similarity percentage for CONTENT clustering (e.g. 85 for 85%%)",
    )

    # status ##########################
    p_status = sub.add_parser("status", help="Show scanned directories")
    # status argument
    p_status.add_argument("workspace", type=Path, help="Workspace directory")

    args = parser.parse_args()

    if args.command == "scan":
        if not args.source.is_dir():
            sys.exit(f"Not a directory: {args.source}")
        cmd_scan(args.source, args.workspace, args.batch)

    elif args.command == "compile":
        if args.similarity is not None:
            threshold = round((1 - args.similarity / 100) * CONTENT_UNIT_BITS)
        else:
            threshold = args.threshold if args.threshold is not None else DEFAULT_THRESHOLD
        cmd_compile(args.workspace, threshold)

    elif args.command == "status":
        cmd_status(args.workspace)


if __name__ == "__main__":
    main()
